+5
-3
src/atpasser/blob/__init__.py
+5
-3
src/atpasser/blob/__init__.py
···
1
1
import cid
2
2
import multihash, hashlib
3
3
4
+
4
5
def generateCID(file):
5
6
hasher = hashlib.new("sha-256")
6
7
while True:
7
8
chunk = file.read(8192)
8
-
if not chunk: break
9
+
if not chunk:
10
+
break
9
11
hasher.update(chunk)
10
-
12
+
11
13
digest = hasher.digest
12
14
mh = multihash.encode(digest, "sha-256")
13
15
14
-
return cid.CIDv1(codec='raw', multihash=mh)
16
+
return cid.CIDv1(codec="raw", multihash=mh)
+41
-37
src/atpasser/data/decoder.py
+41
-37
src/atpasser/data/decoder.py
···
13
13
14
14
class JsonDecoder(json.JSONDecoder):
15
15
"""A JSON decoder that supports ATProto data types.
16
-
16
+
17
17
This decoder extends the standard JSON decoder to handle ATProto-specific
18
18
data types, including bytes, CID links, and typed objects.
19
-
19
+
20
20
Attributes:
21
21
type_hook_registry: Registry for type-specific hooks.
22
22
encoding: The encoding to use for string deserialization.
23
23
"""
24
-
24
+
25
25
def __init__(
26
26
self,
27
27
*,
···
29
29
type_hook_registry: Optional[Any] = None,
30
30
type_processor_registry: Optional[Any] = None,
31
31
encoding: str = "utf-8",
32
-
**kwargs: Any
32
+
**kwargs: Any,
33
33
) -> None:
34
34
"""Initialize the JSON decoder.
35
-
35
+
36
36
Args:
37
37
object_hook: Optional function to call with each decoded object.
38
38
type_hook_registry: Registry for type-specific hooks.
···
45
45
type_hook_registry = type_processor_registry.to_hook_registry()
46
46
elif type_hook_registry is None:
47
47
from .hooks import get_global_registry
48
+
48
49
type_hook_registry = get_global_registry()
49
-
50
+
50
51
# Create a combined object hook that calls both the custom hook and our hook
51
52
combined_hook = self._create_combined_hook(object_hook, type_hook_registry)
52
-
53
+
53
54
super().__init__(object_hook=combined_hook, **kwargs)
54
55
self.type_hook_registry = type_hook_registry
55
56
self.type_processor_registry = type_processor_registry
56
57
self.encoding = encoding
57
-
58
+
58
59
def _create_combined_hook(
59
60
self,
60
61
custom_hook: Optional[Callable[[Dict[str, Any]], Any]],
61
-
type_hook_registry: Optional[Any]
62
+
type_hook_registry: Optional[Any],
62
63
) -> Callable[[Dict[str, Any]], Any]:
63
64
"""Create a combined object hook function.
64
-
65
+
65
66
Args:
66
67
custom_hook: Optional custom object hook function.
67
68
type_hook_registry: Registry for type-specific hooks.
68
-
69
+
69
70
Returns:
70
71
A combined object hook function.
71
72
"""
73
+
72
74
def combined_hook(obj: Dict[str, Any]) -> Any:
73
75
# First, apply our ATProto-specific decoding
74
76
decoded_obj = self._atproto_object_hook(obj)
75
-
77
+
76
78
# Then, apply the custom hook if provided
77
79
if custom_hook is not None:
78
80
decoded_obj = custom_hook(decoded_obj)
79
-
81
+
80
82
return decoded_obj
81
-
83
+
82
84
return combined_hook
83
-
85
+
84
86
def _atproto_object_hook(self, obj: Dict[str, Any]) -> Any:
85
87
"""Handle ATProto-specific object decoding.
86
-
88
+
87
89
Args:
88
90
obj: The object to decode.
89
-
91
+
90
92
Returns:
91
93
The decoded object.
92
94
"""
···
96
98
# If there are other keys, this is invalid
97
99
raise ValueError(f"Invalid $bytes object: {obj}")
98
100
return base64.b64decode(obj["$bytes"].encode(self.encoding))
99
-
101
+
100
102
# Handle $link key (CID parsing)
101
103
elif "$link" in obj:
102
104
if len(obj) != 1:
103
105
# If there are other keys, this is invalid
104
106
raise ValueError(f"Invalid $link object: {obj}")
105
107
return make_cid(obj["$link"])
106
-
108
+
107
109
# Handle $type key (typed objects)
108
110
elif "$type" in obj:
109
111
type_value = obj["$type"]
110
112
remaining_obj = {k: v for k, v in obj.items() if k != "$type"}
111
-
113
+
112
114
# Check if there's a registered type handler
113
115
if self.type_hook_registry is not None:
114
116
handler = self.type_hook_registry.get_handler(type_value)
115
117
if handler is not None:
116
118
return handler(remaining_obj)
117
-
119
+
118
120
# If no handler is registered, return a typed object
119
121
return TypedObject(type_value, remaining_obj)
120
-
122
+
121
123
# Handle nested objects recursively
122
124
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
-
125
+
return {
126
+
k: self._atproto_object_hook(v) if isinstance(v, dict) else v
127
+
for k, v in obj.items()
128
+
}
129
+
126
130
return obj
127
131
128
132
129
133
class TypedObject:
130
134
"""A typed object in the ATProto data model.
131
-
135
+
132
136
This class represents an object with a $type field in the ATProto data model.
133
-
137
+
134
138
Attributes:
135
139
type: The type of the object.
136
140
data: The data associated with the object.
137
141
"""
138
-
142
+
139
143
def __init__(self, type_name: str, data: Dict[str, Any]) -> None:
140
144
"""Initialize a typed object.
141
-
145
+
142
146
Args:
143
147
type_name: The type of the object.
144
148
data: The data associated with the object.
145
149
"""
146
150
self.type_name = type_name
147
151
self.data = data
148
-
152
+
149
153
def __repr__(self) -> str:
150
154
"""Return a string representation of the typed object.
151
-
155
+
152
156
Returns:
153
157
A string representation of the typed object.
154
158
"""
155
159
return f"TypedObject(type_name={self.type_name!r}, data={self.data!r})"
156
-
160
+
157
161
def __eq__(self, other: Any) -> bool:
158
162
"""Check if two typed objects are equal.
159
-
163
+
160
164
Args:
161
165
other: The object to compare with.
162
-
166
+
163
167
Returns:
164
168
True if the objects are equal, False otherwise.
165
169
"""
166
170
if not isinstance(other, TypedObject):
167
171
return False
168
172
return self.type_name == other.type_name and self.data == other.data
169
-
173
+
170
174
def __atproto_json_encode__(self) -> Dict[str, Any]:
171
175
"""Encode the typed object to a JSON-serializable format.
172
-
176
+
173
177
Returns:
174
178
A JSON-serializable representation of the typed object.
175
179
"""
176
180
result = {"$type": self.type_name}
177
181
result.update(self.data)
178
-
return result
182
+
return result
+10
-10
src/atpasser/data/encoder.py
+10
-10
src/atpasser/data/encoder.py
···
13
13
14
14
class JsonEncoder(json.JSONEncoder):
15
15
"""A JSON encoder that supports ATProto data types.
16
-
16
+
17
17
This encoder extends the standard JSON encoder to handle ATProto-specific
18
18
data types, including bytes, CID links, and typed objects.
19
-
19
+
20
20
Attributes:
21
21
encoding (str): The encoding to use for string serialization.
22
22
type_processor_registry: Registry for type-specific processors.
23
23
"""
24
-
24
+
25
25
def __init__(
26
26
self,
27
27
*,
28
28
encoding: str = "utf-8",
29
29
type_processor_registry: Optional[Any] = None,
30
-
**kwargs: Any
30
+
**kwargs: Any,
31
31
) -> None:
32
32
"""Initialize the JSON encoder.
33
-
33
+
34
34
Args:
35
35
encoding: The encoding to use for string serialization.
36
36
type_processor_registry: Registry for type-specific processors.
···
39
39
super().__init__(**kwargs)
40
40
self.encoding = encoding
41
41
self.type_processor_registry = type_processor_registry
42
-
42
+
43
43
def default(self, o: Any) -> Any:
44
44
"""Convert an object to a serializable format.
45
-
45
+
46
46
Args:
47
47
o: The object to serialize.
48
-
48
+
49
49
Returns:
50
50
A serializable representation of the object.
51
-
51
+
52
52
Raises:
53
53
TypeError: If the object is not serializable.
54
54
"""
···
79
79
return [self.default(item) for item in o]
80
80
else:
81
81
# Use the parent class for other types
82
-
return super().default(o)
82
+
return super().default(o)
+51
-46
src/atpasser/data/hooks.py
+51
-46
src/atpasser/data/hooks.py
···
9
9
from typing import Any, Callable, Dict, Optional, TypeVar, Union
10
10
11
11
# Type variable for the decorated function
12
-
F = TypeVar('F', bound=Callable[..., Any])
12
+
F = TypeVar("F", bound=Callable[..., Any])
13
13
14
14
15
15
class TypeHookRegistry:
16
16
"""Registry for type-specific hooks in the ATProto JSON decoder.
17
-
17
+
18
18
This class maintains a registry of type-specific hooks that can be used
19
19
to customize the decoding of objects with $type keys in the ATProto data model.
20
-
20
+
21
21
Attributes:
22
22
_handlers: Dictionary mapping type names to handler functions.
23
23
"""
24
-
24
+
25
25
def __init__(self) -> None:
26
26
"""Initialize the type hook registry."""
27
27
self._handlers: Dict[str, Callable[[Dict[str, Any]], Any]] = {}
28
-
28
+
29
29
def register(self, type_name: str) -> Callable[[F], F]:
30
30
"""Register a type handler function.
31
-
31
+
32
32
This method can be used as a decorator to register a function as a handler
33
33
for a specific type.
34
-
34
+
35
35
Args:
36
36
type_name: The name of the type to handle.
37
-
37
+
38
38
Returns:
39
39
A decorator function that registers the decorated function as a handler.
40
-
40
+
41
41
Example:
42
42
>>> registry = TypeHookRegistry()
43
-
>>>
43
+
>>>
44
44
>>> @registry.register("app.bsky.feed.post")
45
45
... def handle_post(data: Dict[str, Any]) -> Any:
46
46
... return Post(**data)
47
47
"""
48
+
48
49
def decorator(func: F) -> F:
49
50
self._handlers[type_name] = func
50
51
return func
51
-
52
+
52
53
return decorator
53
-
54
-
def register_handler(self, type_name: str, handler: Callable[[Dict[str, Any]], Any]) -> None:
54
+
55
+
def register_handler(
56
+
self, type_name: str, handler: Callable[[Dict[str, Any]], Any]
57
+
) -> None:
55
58
"""Register a type handler function directly.
56
-
59
+
57
60
Args:
58
61
type_name: The name of the type to handle.
59
62
handler: The function to call when decoding objects of this type.
60
-
63
+
61
64
Example:
62
65
>>> registry = TypeHookRegistry()
63
-
>>>
66
+
>>>
64
67
>>> def handle_post(data: Dict[str, Any]) -> Any:
65
68
... return Post(**data)
66
-
>>>
69
+
>>>
67
70
>>> registry.register_handler("app.bsky.feed.post", handle_post)
68
71
"""
69
72
self._handlers[type_name] = handler
70
-
73
+
71
74
def unregister(self, type_name: str) -> None:
72
75
"""Unregister a type handler function.
73
-
76
+
74
77
Args:
75
78
type_name: The name of the type to unregister.
76
79
"""
77
80
if type_name in self._handlers:
78
81
del self._handlers[type_name]
79
-
82
+
80
83
def get_handler(self, type_name: str) -> Optional[Callable[[Dict[str, Any]], Any]]:
81
84
"""Get the handler function for a specific type.
82
-
85
+
83
86
Args:
84
87
type_name: The name of the type to get the handler for.
85
-
88
+
86
89
Returns:
87
90
The handler function for the specified type, or None if no handler
88
91
is registered.
89
92
"""
90
93
return self._handlers.get(type_name)
91
-
94
+
92
95
def has_handler(self, type_name: str) -> bool:
93
96
"""Check if a handler is registered for a specific type.
94
-
97
+
95
98
Args:
96
99
type_name: The name of the type to check.
97
-
100
+
98
101
Returns:
99
102
True if a handler is registered for the specified type, False otherwise.
100
103
"""
101
104
return type_name in self._handlers
102
-
105
+
103
106
def clear(self) -> None:
104
107
"""Clear all registered handlers."""
105
108
self._handlers.clear()
106
-
109
+
107
110
def get_registered_types(self) -> set:
108
111
"""Get the set of all registered type names.
109
-
112
+
110
113
Returns:
111
114
A set of all registered type names.
112
115
"""
···
119
122
120
123
def type_handler(type_name: str) -> Callable[[F], F]:
121
124
"""Register a global type handler function.
122
-
125
+
123
126
This decorator registers a function as a global handler for a specific type
124
127
in the ATProto data model.
125
-
128
+
126
129
Args:
127
130
type_name: The name of the type to handle.
128
-
131
+
129
132
Returns:
130
133
A decorator function that registers the decorated function as a handler.
131
-
134
+
132
135
Example:
133
136
>>> @type_handler("app.bsky.feed.post")
134
137
... def handle_post(data: Dict[str, Any]) -> Any:
···
139
142
140
143
def get_global_registry() -> TypeHookRegistry:
141
144
"""Get the global type hook registry.
142
-
145
+
143
146
Returns:
144
147
The global TypeHookRegistry instance.
145
148
"""
146
149
return _global_registry
147
150
148
151
149
-
def register_type_handler(type_name: str, handler: Callable[[Dict[str, Any]], Any]) -> None:
152
+
def register_type_handler(
153
+
type_name: str, handler: Callable[[Dict[str, Any]], Any]
154
+
) -> None:
150
155
"""Register a global type handler function directly.
151
-
156
+
152
157
Args:
153
158
type_name: The name of the type to handle.
154
159
handler: The function to call when decoding objects of this type.
155
-
160
+
156
161
Example:
157
162
>>> def handle_post(data: Dict[str, Any]) -> Any:
158
163
... return Post(**data)
159
-
>>>
164
+
>>>
160
165
>>> register_type_handler("app.bsky.feed.post", handle_post)
161
166
"""
162
167
_global_registry.register_handler(type_name, handler)
···
164
169
165
170
def unregister_type_handler(type_name: str) -> None:
166
171
"""Unregister a global type handler function.
167
-
172
+
168
173
Args:
169
174
type_name: The name of the type to unregister.
170
175
"""
···
173
178
174
179
def get_type_handler(type_name: str) -> Optional[Callable[[Dict[str, Any]], Any]]:
175
180
"""Get the global handler function for a specific type.
176
-
181
+
177
182
Args:
178
183
type_name: The name of the type to get the handler for.
179
-
184
+
180
185
Returns:
181
186
The handler function for the specified type, or None if no handler
182
187
is registered.
···
186
191
187
192
def has_type_handler(type_name: str) -> bool:
188
193
"""Check if a global handler is registered for a specific type.
189
-
194
+
190
195
Args:
191
196
type_name: The name of the type to check.
192
-
197
+
193
198
Returns:
194
199
True if a handler is registered for the specified type, False otherwise.
195
200
"""
···
203
208
204
209
def get_registered_types() -> set:
205
210
"""Get the set of all globally registered type names.
206
-
211
+
207
212
Returns:
208
213
A set of all registered type names.
209
214
"""
···
212
217
213
218
def create_registry() -> TypeHookRegistry:
214
219
"""Create a new type hook registry.
215
-
220
+
216
221
This function creates a new, independent registry that can be used
217
222
instead of the global registry.
218
-
223
+
219
224
Returns:
220
225
A new TypeHookRegistry instance.
221
226
"""
222
-
return TypeHookRegistry()
227
+
return TypeHookRegistry()
+120
-114
src/atpasser/data/types.py
+120
-114
src/atpasser/data/types.py
···
10
10
from .hooks import TypeHookRegistry
11
11
12
12
# Type variable for the decorated class
13
-
T = TypeVar('T')
13
+
T = TypeVar("T")
14
14
15
15
16
16
class TypeProcessor:
17
17
"""A type processor for ATProto JSON objects.
18
-
18
+
19
19
This class represents a processor for a specific type in the ATProto data model.
20
20
It contains information about how to convert JSON data to Python objects and
21
21
vice versa.
22
-
22
+
23
23
Attributes:
24
24
type_name: The name of the type this processor handles.
25
25
decoder: The function to decode JSON data to a Python object.
26
26
encoder: The function to encode a Python object to JSON data.
27
27
priority: The priority of this processor (higher values = higher priority).
28
28
"""
29
-
29
+
30
30
def __init__(
31
31
self,
32
32
type_name: str,
33
33
decoder: Optional[Callable[[Dict[str, Any]], Any]] = None,
34
34
encoder: Optional[Callable[[Any], Dict[str, Any]]] = None,
35
-
priority: int = 0
35
+
priority: int = 0,
36
36
) -> None:
37
37
"""Initialize a type processor.
38
-
38
+
39
39
Args:
40
40
type_name: The name of the type this processor handles.
41
41
decoder: The function to decode JSON data to a Python object.
···
46
46
self.decoder = decoder
47
47
self.encoder = encoder
48
48
self.priority = priority
49
-
49
+
50
50
def decode(self, data: Dict[str, Any]) -> Any:
51
51
"""Decode JSON data to a Python object.
52
-
52
+
53
53
Args:
54
54
data: The JSON data to decode.
55
-
55
+
56
56
Returns:
57
57
The decoded Python object.
58
-
58
+
59
59
Raises:
60
60
ValueError: If no decoder is registered.
61
61
"""
62
62
if self.decoder is None:
63
63
raise ValueError(f"No decoder registered for type {self.type_name}")
64
64
return self.decoder(data)
65
-
65
+
66
66
def encode(self, obj: Any) -> Dict[str, Any]:
67
67
"""Encode a Python object to JSON data.
68
-
68
+
69
69
Args:
70
70
obj: The Python object to encode.
71
-
71
+
72
72
Returns:
73
73
The encoded JSON data.
74
-
74
+
75
75
Raises:
76
76
ValueError: If no encoder is registered.
77
77
"""
···
82
82
83
83
class TypeProcessorRegistry:
84
84
"""Registry for type processors in the ATProto JSON decoder.
85
-
85
+
86
86
This class maintains a registry of type processors that can be used
87
87
to customize the encoding and decoding of objects with $type keys in
88
88
the ATProto data model.
89
-
89
+
90
90
Attributes:
91
91
_processors: Dictionary mapping type names to processor lists.
92
92
"""
93
-
93
+
94
94
def __init__(self) -> None:
95
95
"""Initialize the type processor registry."""
96
96
self._processors: Dict[str, List[TypeProcessor]] = {}
97
-
97
+
98
98
def register_processor(self, processor: TypeProcessor) -> None:
99
99
"""Register a type processor.
100
-
100
+
101
101
Args:
102
102
processor: The type processor to register.
103
103
"""
104
104
if processor.type_name not in self._processors:
105
105
self._processors[processor.type_name] = []
106
-
106
+
107
107
self._processors[processor.type_name].append(processor)
108
108
# Sort processors by priority (descending)
109
-
self._processors[processor.type_name].sort(key=lambda p: p.priority, reverse=True)
110
-
109
+
self._processors[processor.type_name].sort(
110
+
key=lambda p: p.priority, reverse=True
111
+
)
112
+
111
113
def register(
112
-
self,
113
-
type_name: str,
114
-
priority: int = 0
114
+
self, type_name: str, priority: int = 0
115
115
) -> Callable[[Callable[[Dict[str, Any]], Any]], Callable[[Dict[str, Any]], Any]]:
116
116
"""Register a type decoder function.
117
-
117
+
118
118
This method can be used as a decorator to register a function as a decoder
119
119
for a specific type.
120
-
120
+
121
121
Args:
122
122
type_name: The name of the type to handle.
123
123
priority: The priority of this processor (higher values = higher priority).
124
-
124
+
125
125
Returns:
126
126
A decorator function that registers the decorated function as a decoder.
127
-
127
+
128
128
Example:
129
129
>>> registry = TypeProcessorRegistry()
130
-
>>>
130
+
>>>
131
131
>>> @registry.register("app.bsky.feed.post", priority=10)
132
132
... def decode_post(data: Dict[str, Any]) -> Any:
133
133
... return Post(**data)
134
134
"""
135
-
def decorator(func: Callable[[Dict[str, Any]], Any]) -> Callable[[Dict[str, Any]], Any]:
135
+
136
+
def decorator(
137
+
func: Callable[[Dict[str, Any]], Any],
138
+
) -> Callable[[Dict[str, Any]], Any]:
136
139
processor = TypeProcessor(type_name, decoder=func, priority=priority)
137
140
self.register_processor(processor)
138
141
return func
139
-
142
+
140
143
return decorator
141
-
144
+
142
145
def register_encoder(
143
-
self,
144
-
type_name: str,
145
-
priority: int = 0
146
+
self, type_name: str, priority: int = 0
146
147
) -> Callable[[Callable[[Any], Dict[str, Any]]], Callable[[Any], Dict[str, Any]]]:
147
148
"""Register a type encoder function.
148
-
149
+
149
150
This method can be used as a decorator to register a function as an encoder
150
151
for a specific type.
151
-
152
+
152
153
Args:
153
154
type_name: The name of the type to handle.
154
155
priority: The priority of this processor (higher values = higher priority).
155
-
156
+
156
157
Returns:
157
158
A decorator function that registers the decorated function as an encoder.
158
-
159
+
159
160
Example:
160
161
>>> registry = TypeProcessorRegistry()
161
-
>>>
162
+
>>>
162
163
>>> @registry.register_encoder("app.bsky.feed.post", priority=10)
163
164
... def encode_post(post: Post) -> Dict[str, Any]:
164
165
... return {"text": post.text, "createdAt": post.created_at}
165
166
"""
166
-
def decorator(func: Callable[[Any], Dict[str, Any]]) -> Callable[[Any], Dict[str, Any]]:
167
+
168
+
def decorator(
169
+
func: Callable[[Any], Dict[str, Any]],
170
+
) -> Callable[[Any], Dict[str, Any]]:
167
171
# Check if a processor for this type already exists
168
172
if type_name in self._processors:
169
173
for processor in self._processors[type_name]:
···
173
177
break
174
178
else:
175
179
# No decoder found, create a new processor
176
-
processor = TypeProcessor(type_name, encoder=func, priority=priority)
180
+
processor = TypeProcessor(
181
+
type_name, encoder=func, priority=priority
182
+
)
177
183
self.register_processor(processor)
178
184
else:
179
185
# No processor exists, create a new one
180
186
processor = TypeProcessor(type_name, encoder=func, priority=priority)
181
187
self.register_processor(processor)
182
-
188
+
183
189
return func
184
-
190
+
185
191
return decorator
186
-
192
+
187
193
def register_class(
188
-
self,
189
-
type_name: str,
190
-
priority: int = 0
194
+
self, type_name: str, priority: int = 0
191
195
) -> Callable[[Type[T]], Type[T]]:
192
196
"""Register a class for both encoding and decoding.
193
-
197
+
194
198
This method can be used as a decorator to register a class for both
195
199
encoding and decoding of a specific type.
196
-
200
+
197
201
The class must have a class method `from_json` that takes a dictionary
198
202
and returns an instance of the class, and an instance method `to_json`
199
203
that returns a dictionary.
200
-
204
+
201
205
Args:
202
206
type_name: The name of the type to handle.
203
207
priority: The priority of this processor (higher values = higher priority).
204
-
208
+
205
209
Returns:
206
210
A decorator function that registers the decorated class.
207
-
211
+
208
212
Example:
209
213
>>> registry = TypeProcessorRegistry()
210
-
>>>
214
+
>>>
211
215
>>> @registry.register_class("app.bsky.feed.post", priority=10)
212
216
... class Post:
213
217
... def __init__(self, text: str, created_at: str) -> None:
214
218
... self.text = text
215
219
... self.created_at = created_at
216
-
...
220
+
...
217
221
... @classmethod
218
222
... def from_json(cls, data: Dict[str, Any]) -> "Post":
219
223
... return cls(data["text"], data["createdAt"])
220
-
...
224
+
...
221
225
... def to_json(self) -> Dict[str, Any]:
222
226
... return {"text": self.text, "createdAt": self.created_at}
223
227
"""
228
+
224
229
def decorator(cls: Type[T]) -> Type[T]:
225
230
# Create decoder from class method
226
231
if hasattr(cls, "from_json"):
···
232
237
# Create a decoder that passes the data as keyword arguments
233
238
decoder = lambda data: cls(**data)
234
239
else:
235
-
raise ValueError(f"Class {cls.__name__} has no from_json method or compatible __init__")
236
-
240
+
raise ValueError(
241
+
f"Class {cls.__name__} has no from_json method or compatible __init__"
242
+
)
243
+
237
244
# Create encoder from instance method
238
245
if hasattr(cls, "to_json"):
239
246
encoder = lambda obj: obj.to_json()
240
247
else:
241
248
raise ValueError(f"Class {cls.__name__} has no to_json method")
242
-
249
+
243
250
# Register the processor
244
-
processor = TypeProcessor(type_name, decoder=decoder, encoder=encoder, priority=priority)
251
+
processor = TypeProcessor(
252
+
type_name, decoder=decoder, encoder=encoder, priority=priority
253
+
)
245
254
self.register_processor(processor)
246
-
255
+
247
256
return cls
248
-
257
+
249
258
return decorator
250
-
259
+
251
260
def unregister(self, type_name: str, priority: Optional[int] = None) -> None:
252
261
"""Unregister type processors.
253
-
262
+
254
263
Args:
255
264
type_name: The name of the type to unregister.
256
265
priority: If specified, only unregister processors with this priority.
···
264
273
else:
265
274
# Remove all processors for this type
266
275
del self._processors[type_name]
267
-
276
+
268
277
def get_decoder(self, type_name: str) -> Optional[Callable[[Dict[str, Any]], Any]]:
269
278
"""Get the decoder function for a specific type.
270
-
279
+
271
280
Args:
272
281
type_name: The name of the type to get the decoder for.
273
-
282
+
274
283
Returns:
275
284
The decoder function for the specified type, or None if no decoder
276
285
is registered.
···
279
288
# Return the decoder of the highest priority processor
280
289
return self._processors[type_name][0].decoder
281
290
return None
282
-
291
+
283
292
def get_encoder(self, type_name: str) -> Optional[Callable[[Any], Dict[str, Any]]]:
284
293
"""Get the encoder function for a specific type.
285
-
294
+
286
295
Args:
287
296
type_name: The name of the type to get the encoder for.
288
-
297
+
289
298
Returns:
290
299
The encoder function for the specified type, or None if no encoder
291
300
is registered.
···
294
303
# Return the encoder of the highest priority processor
295
304
return self._processors[type_name][0].encoder
296
305
return None
297
-
306
+
298
307
def has_processor(self, type_name: str) -> bool:
299
308
"""Check if a processor is registered for a specific type.
300
-
309
+
301
310
Args:
302
311
type_name: The name of the type to check.
303
-
312
+
304
313
Returns:
305
314
True if a processor is registered for the specified type, False otherwise.
306
315
"""
307
316
return type_name in self._processors and bool(self._processors[type_name])
308
-
317
+
309
318
def clear(self) -> None:
310
319
"""Clear all registered processors."""
311
320
self._processors.clear()
312
-
321
+
313
322
def get_registered_types(self) -> set:
314
323
"""Get the set of all registered type names.
315
-
324
+
316
325
Returns:
317
326
A set of all registered type names.
318
327
"""
319
328
return set(self._processors.keys())
320
-
329
+
321
330
def to_hook_registry(self) -> TypeHookRegistry:
322
331
"""Convert this processor registry to a hook registry.
323
-
332
+
324
333
This method creates a TypeHookRegistry that uses the decoders from
325
334
this processor registry.
326
-
335
+
327
336
Returns:
328
337
A TypeHookRegistry with the same decoders as this processor registry.
329
338
"""
330
339
hook_registry = TypeHookRegistry()
331
-
340
+
332
341
for type_name, processors in self._processors.items():
333
342
if processors and processors[0].decoder is not None:
334
343
hook_registry.register_handler(type_name, processors[0].decoder)
335
-
344
+
336
345
return hook_registry
337
346
338
347
···
341
350
342
351
343
352
def register_type(
344
-
type_name: str,
345
-
priority: int = 0
353
+
type_name: str, priority: int = 0
346
354
) -> Callable[[Callable[[Dict[str, Any]], Any]], Callable[[Dict[str, Any]], Any]]:
347
355
"""Register a global type decoder function.
348
-
356
+
349
357
This decorator registers a function as a global decoder for a specific type
350
358
in the ATProto data model.
351
-
359
+
352
360
Args:
353
361
type_name: The name of the type to handle.
354
362
priority: The priority of this processor (higher values = higher priority).
355
-
363
+
356
364
Returns:
357
365
A decorator function that registers the decorated function as a decoder.
358
-
366
+
359
367
Example:
360
368
>>> @register_type("app.bsky.feed.post", priority=10)
361
369
... def decode_post(data: Dict[str, Any]) -> Any:
···
366
374
367
375
def get_global_processor_registry() -> TypeProcessorRegistry:
368
376
"""Get the global type processor registry.
369
-
377
+
370
378
Returns:
371
379
The global TypeProcessorRegistry instance.
372
380
"""
···
374
382
375
383
376
384
def register_type_encoder(
377
-
type_name: str,
378
-
priority: int = 0
385
+
type_name: str, priority: int = 0
379
386
) -> Callable[[Callable[[Any], Dict[str, Any]]], Callable[[Any], Dict[str, Any]]]:
380
387
"""Register a global type encoder function.
381
-
388
+
382
389
This decorator registers a function as a global encoder for a specific type
383
390
in the ATProto data model.
384
-
391
+
385
392
Args:
386
393
type_name: The name of the type to handle.
387
394
priority: The priority of this processor (higher values = higher priority).
388
-
395
+
389
396
Returns:
390
397
A decorator function that registers the decorated function as an encoder.
391
-
398
+
392
399
Example:
393
400
>>> @register_type_encoder("app.bsky.feed.post", priority=10)
394
401
... def encode_post(post: Post) -> Dict[str, Any]:
···
398
405
399
406
400
407
def register_type_class(
401
-
type_name: str,
402
-
priority: int = 0
408
+
type_name: str, priority: int = 0
403
409
) -> Callable[[Type[T]], Type[T]]:
404
410
"""Register a class for both global encoding and decoding.
405
-
411
+
406
412
This decorator registers a class for both encoding and decoding of a specific type
407
413
in the ATProto data model.
408
-
414
+
409
415
Args:
410
416
type_name: The name of the type to handle.
411
417
priority: The priority of this processor (higher values = higher priority).
412
-
418
+
413
419
Returns:
414
420
A decorator function that registers the decorated class.
415
-
421
+
416
422
Example:
417
423
>>> @register_type_class("app.bsky.feed.post", priority=10)
418
424
... class Post:
419
425
... def __init__(self, text: str, created_at: str) -> None:
420
426
... self.text = text
421
427
... self.created_at = created_at
422
-
...
428
+
...
423
429
... @classmethod
424
430
... def from_json(cls, data: Dict[str, Any]) -> "Post":
425
431
... return cls(data["text"], data["createdAt"])
426
-
...
432
+
...
427
433
... def to_json(self) -> Dict[str, Any]:
428
434
... return {"text": self.text, "createdAt": self.created_at}
429
435
"""
···
432
438
433
439
def unregister_type(type_name: str, priority: Optional[int] = None) -> None:
434
440
"""Unregister global type processors.
435
-
441
+
436
442
Args:
437
443
type_name: The name of the type to unregister.
438
444
priority: If specified, only unregister processors with this priority.
···
442
448
443
449
def get_type_decoder(type_name: str) -> Optional[Callable[[Dict[str, Any]], Any]]:
444
450
"""Get the global decoder function for a specific type.
445
-
451
+
446
452
Args:
447
453
type_name: The name of the type to get the decoder for.
448
-
454
+
449
455
Returns:
450
456
The decoder function for the specified type, or None if no decoder
451
457
is registered.
···
455
461
456
462
def get_type_encoder(type_name: str) -> Optional[Callable[[Any], Dict[str, Any]]]:
457
463
"""Get the global encoder function for a specific type.
458
-
464
+
459
465
Args:
460
466
type_name: The name of the type to get the encoder for.
461
-
467
+
462
468
Returns:
463
469
The encoder function for the specified type, or None if no encoder
464
470
is registered.
···
468
474
469
475
def has_type_processor(type_name: str) -> bool:
470
476
"""Check if a global processor is registered for a specific type.
471
-
477
+
472
478
Args:
473
479
type_name: The name of the type to check.
474
-
480
+
475
481
Returns:
476
482
True if a processor is registered for the specified type, False otherwise.
477
483
"""
···
485
491
486
492
def get_registered_types() -> set:
487
493
"""Get the set of all globally registered type names.
488
-
494
+
489
495
Returns:
490
496
A set of all registered type names.
491
497
"""
···
494
500
495
501
def create_processor_registry() -> TypeProcessorRegistry:
496
502
"""Create a new type processor registry.
497
-
503
+
498
504
This function creates a new, independent registry that can be used
499
505
instead of the global registry.
500
-
506
+
501
507
Returns:
502
508
A new TypeProcessorRegistry instance.
503
509
"""
504
-
return TypeProcessorRegistry()
510
+
return TypeProcessorRegistry()
+42
-35
src/atpasser/data/wrapper.py
+42
-35
src/atpasser/data/wrapper.py
···
29
29
sort_keys: bool = False,
30
30
encoding: str = "utf-8",
31
31
type_processor_registry: Optional[TypeProcessorRegistry] = None,
32
-
**kwargs: Any
32
+
**kwargs: Any,
33
33
) -> None:
34
34
"""Serialize obj as a JSON formatted stream to fp.
35
-
35
+
36
36
This function is similar to json.dump() but supports ATProto-specific
37
37
data types, including bytes, CID links, and typed objects.
38
-
38
+
39
39
Args:
40
40
obj: The object to serialize.
41
41
fp: A file-like object with a write() method.
···
70
70
"""
71
71
if cls is None:
72
72
cls = JsonEncoder
73
-
73
+
74
74
# Use the global type processor registry if none is provided
75
75
if type_processor_registry is None:
76
76
from .types import get_global_processor_registry
77
+
77
78
type_processor_registry = get_global_processor_registry()
78
-
79
+
79
80
# 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
-
81
+
encoder = cls(
82
+
encoding=encoding, type_processor_registry=type_processor_registry, **kwargs
83
+
)
84
+
82
85
# Use the standard json.dump with our custom encoder
83
86
json.dump(
84
87
obj,
···
92
95
separators=separators,
93
96
default=default,
94
97
sort_keys=sort_keys,
95
-
**kwargs
98
+
**kwargs,
96
99
)
97
100
98
101
···
110
113
sort_keys: bool = False,
111
114
encoding: str = "utf-8",
112
115
type_processor_registry: Optional[TypeProcessorRegistry] = None,
113
-
**kwargs: Any
116
+
**kwargs: Any,
114
117
) -> str:
115
118
"""Serialize obj to a JSON formatted string.
116
-
119
+
117
120
This function is similar to json.dumps() but supports ATProto-specific
118
121
data types, including bytes, CID links, and typed objects.
119
-
122
+
120
123
Args:
121
124
obj: The object to serialize.
122
125
skipkeys: If True, dict keys that are not basic types (str, int, float,
···
147
150
encoding: The encoding to use for string serialization.
148
151
type_processor_registry: Registry for type-specific processors.
149
152
**kwargs: Additional keyword arguments to pass to the JSON encoder.
150
-
153
+
151
154
Returns:
152
155
A JSON formatted string.
153
156
"""
154
157
if cls is None:
155
158
cls = JsonEncoder
156
-
159
+
157
160
# 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
-
161
+
encoder = cls(
162
+
encoding=encoding, type_processor_registry=type_processor_registry, **kwargs
163
+
)
164
+
160
165
# Use the standard json.dumps with our custom encoder
161
166
return json.dumps(
162
167
obj,
···
169
174
separators=separators,
170
175
default=default,
171
176
sort_keys=sort_keys,
172
-
**kwargs
177
+
**kwargs,
173
178
)
174
179
175
180
···
185
190
type_hook_registry: Optional[TypeHookRegistry] = None,
186
191
type_processor_registry: Optional[TypeProcessorRegistry] = None,
187
192
encoding: str = "utf-8",
188
-
**kwargs: Any
193
+
**kwargs: Any,
189
194
) -> Any:
190
195
"""Deserialize fp (a .read()-supporting text file or binary file containing
191
196
a JSON document) to a Python object.
192
-
197
+
193
198
This function is similar to json.load() but supports ATProto-specific
194
199
data types, including bytes, CID links, and typed objects.
195
-
200
+
196
201
Args:
197
202
fp: A .read()-supporting text file or binary file containing a JSON document.
198
203
cls: A custom JSONDecoder subclass. If not specified, JsonDecoder is used.
···
220
225
type_processor_registry: Registry for type-specific processors.
221
226
encoding: The encoding to use for string deserialization.
222
227
**kwargs: Additional keyword arguments to pass to the JSON decoder.
223
-
228
+
224
229
Returns:
225
230
A Python object.
226
231
"""
227
232
if cls is None:
228
233
cls = JsonDecoder
229
-
234
+
230
235
# Use the global type hook registry if none is provided
231
236
if type_hook_registry is None and type_processor_registry is None:
232
237
from .hooks import get_global_registry
238
+
233
239
type_hook_registry = get_global_registry()
234
240
elif type_processor_registry is not None:
235
241
# Convert the type processor registry to a hook registry
236
242
type_hook_registry = type_processor_registry.to_hook_registry()
237
-
243
+
238
244
# Create a decoder instance with the specified parameters
239
245
decoder = cls(
240
246
object_hook=object_hook,
241
247
type_hook_registry=type_hook_registry,
242
248
encoding=encoding,
243
-
**kwargs
249
+
**kwargs,
244
250
)
245
-
251
+
246
252
# Use the standard json.load with our custom decoder
247
253
return json.load(
248
254
fp,
···
252
258
parse_int=parse_int,
253
259
parse_constant=parse_constant,
254
260
object_pairs_hook=object_pairs_hook,
255
-
**kwargs
261
+
**kwargs,
256
262
)
257
263
258
264
···
268
274
type_hook_registry: Optional[TypeHookRegistry] = None,
269
275
type_processor_registry: Optional[TypeProcessorRegistry] = None,
270
276
encoding: str = "utf-8",
271
-
**kwargs: Any
277
+
**kwargs: Any,
272
278
) -> Any:
273
279
"""Deserialize s (a str, bytes or bytearray instance containing a JSON document)
274
280
to a Python object.
275
-
281
+
276
282
This function is similar to json.loads() but supports ATProto-specific
277
283
data types, including bytes, CID links, and typed objects.
278
-
284
+
279
285
Args:
280
286
s: A str, bytes or bytearray instance containing a JSON document.
281
287
cls: A custom JSONDecoder subclass. If not specified, JsonDecoder is used.
···
303
309
type_processor_registry: Registry for type-specific processors.
304
310
encoding: The encoding to use for string deserialization.
305
311
**kwargs: Additional keyword arguments to pass to the JSON decoder.
306
-
312
+
307
313
Returns:
308
314
A Python object.
309
315
"""
310
316
if cls is None:
311
317
cls = JsonDecoder
312
-
318
+
313
319
# Use the global type hook registry if none is provided
314
320
if type_hook_registry is None and type_processor_registry is None:
315
321
from .hooks import get_global_registry
322
+
316
323
type_hook_registry = get_global_registry()
317
324
elif type_processor_registry is not None:
318
325
# Convert the type processor registry to a hook registry
319
326
type_hook_registry = type_processor_registry.to_hook_registry()
320
-
327
+
321
328
# Create a decoder instance with the specified parameters
322
329
decoder = cls(
323
330
object_hook=object_hook,
324
331
type_hook_registry=type_hook_registry,
325
332
encoding=encoding,
326
-
**kwargs
333
+
**kwargs,
327
334
)
328
-
335
+
329
336
# Use the standard json.loads with our custom decoder
330
337
return json.loads(
331
338
s,
···
335
342
parse_int=parse_int,
336
343
parse_constant=parse_constant,
337
344
object_pairs_hook=object_pairs_hook,
338
-
**kwargs
339
-
)
345
+
**kwargs,
346
+
)
+1
-1
tests/__init__.py
+1
-1
tests/__init__.py
+1
-1
tests/uri/__init__.py
+1
-1
tests/uri/__init__.py
+16
-16
tests/uri/test_did.py
+16
-16
tests/uri/test_did.py
···
12
12
"""Test creating a DID with a valid did:plc format."""
13
13
did_str = "did:plc:z72i7hdynmk6r22z27h6tvur"
14
14
did = DID(did_str)
15
-
15
+
16
16
assert str(did) == did_str
17
17
assert did.uri == did_str
18
18
···
20
20
"""Test creating a DID with a valid did:web format."""
21
21
did_str = "did:web:blueskyweb.xyz"
22
22
did = DID(did_str)
23
-
23
+
24
24
assert str(did) == did_str
25
25
assert did.uri == did_str
26
26
···
28
28
"""Test creating a DID with various valid characters."""
29
29
did_str = "did:method:val:two-with_underscores.and-dashes"
30
30
did = DID(did_str)
31
-
31
+
32
32
assert str(did) == did_str
33
33
assert did.uri == did_str
34
34
35
35
def test_invalid_did_wrong_format(self):
36
36
"""Test that a DID with wrong format raises InvalidDIDError."""
37
37
did_str = "not-a-did"
38
-
38
+
39
39
with pytest.raises(InvalidDIDError, match="invalid format"):
40
40
DID(did_str)
41
41
42
42
def test_invalid_did_uppercase_method(self):
43
43
"""Test that a DID with uppercase method raises InvalidDIDError."""
44
44
did_str = "did:METHOD:val"
45
-
45
+
46
46
with pytest.raises(InvalidDIDError, match="invalid format"):
47
47
DID(did_str)
48
48
49
49
def test_invalid_did_method_with_numbers(self):
50
50
"""Test that a DID with method containing numbers raises InvalidDIDError."""
51
51
did_str = "did:m123:val"
52
-
52
+
53
53
with pytest.raises(InvalidDIDError, match="invalid format"):
54
54
DID(did_str)
55
55
56
56
def test_invalid_did_empty_identifier(self):
57
57
"""Test that a DID with empty identifier raises InvalidDIDError."""
58
58
did_str = "did:method:"
59
-
59
+
60
60
with pytest.raises(InvalidDIDError, match="invalid format"):
61
61
DID(did_str)
62
62
63
63
def test_invalid_did_ends_with_colon(self):
64
64
"""Test that a DID ending with colon raises InvalidDIDError."""
65
65
did_str = "did:method:val:"
66
-
66
+
67
67
with pytest.raises(InvalidDIDError, match="invalid format"):
68
68
DID(did_str)
69
69
···
72
72
# Create a DID that exceeds the 2048 character limit
73
73
long_identifier = "a" * 2040
74
74
did_str = f"did:method:{long_identifier}"
75
-
75
+
76
76
with pytest.raises(InvalidDIDError, match="exceeds maximum length"):
77
77
DID(did_str)
78
78
···
81
81
did_str = "did:plc:z72i7hdynmk6r22z27h6tvur"
82
82
did1 = DID(did_str)
83
83
did2 = DID(did_str)
84
-
84
+
85
85
assert did1 == did2
86
86
assert did1 != "not a did object"
87
87
···
89
89
"""Test DID string representation."""
90
90
did_str = "did:plc:z72i7hdynmk6r22z27h6tvur"
91
91
did = DID(did_str)
92
-
92
+
93
93
assert str(did) == did_str
94
94
95
95
def test_did_fetch_plc_method(self):
96
96
"""Test fetching a DID document for did:plc method."""
97
97
did_str = "did:plc:z72i7hdynmk6r22z27h6tvur"
98
98
did = DID(did_str)
99
-
99
+
100
100
# This test may fail if there's no internet connection or if the PLC directory is down
101
101
try:
102
102
document = did.fetch()
···
110
110
"""Test fetching a DID document for did:web method."""
111
111
did_str = "did:web:blueskyweb.xyz"
112
112
did = DID(did_str)
113
-
113
+
114
114
# This test may fail if there's no internet connection or if the web server is down
115
115
try:
116
116
document = did.fetch()
···
124
124
"""Test that fetching a DID document with unsupported method raises InvalidDIDError."""
125
125
did_str = "did:unsupported:method"
126
126
did = DID(did_str)
127
-
127
+
128
128
with pytest.raises(InvalidDIDError, match="unsupported DID method"):
129
129
did.fetch()
130
130
···
132
132
"""Test that fetching a DID document with empty domain raises InvalidDIDError."""
133
133
did_str = "did:web:"
134
134
did = DID(did_str)
135
-
135
+
136
136
with pytest.raises(InvalidDIDError, match="invalid format"):
137
-
did.fetch()
137
+
did.fetch()
+22
-22
tests/uri/test_handle.py
+22
-22
tests/uri/test_handle.py
···
12
12
"""Test creating a Handle with a valid simple format."""
13
13
handle_str = "example.com"
14
14
handle = Handle(handle_str)
15
-
15
+
16
16
assert str(handle) == handle_str
17
17
assert handle.handle == handle_str
18
18
···
20
20
"""Test creating a Handle with a valid subdomain format."""
21
21
handle_str = "subdomain.example.com"
22
22
handle = Handle(handle_str)
23
-
23
+
24
24
assert str(handle) == handle_str
25
25
assert handle.handle == handle_str
26
26
···
28
28
"""Test creating a Handle with a valid format containing hyphens."""
29
29
handle_str = "my-example.com"
30
30
handle = Handle(handle_str)
31
-
31
+
32
32
assert str(handle) == handle_str
33
33
assert handle.handle == handle_str
34
34
···
36
36
"""Test creating a Handle with a valid format containing numbers."""
37
37
handle_str = "example123.com"
38
38
handle = Handle(handle_str)
39
-
39
+
40
40
assert str(handle) == handle_str
41
41
assert handle.handle == handle_str
42
42
···
44
44
"""Test creating a Handle with a valid long domain name."""
45
45
handle_str = "a" * 63 + "." + "b" * 63 + "." + "c" * 63 + ".com"
46
46
handle = Handle(handle_str)
47
-
47
+
48
48
assert str(handle) == handle_str
49
49
assert handle.handle == handle_str
50
50
···
53
53
# Create a handle that exceeds the 253 character limit
54
54
long_handle = "a" * 254
55
55
handle_str = f"{long_handle}.com"
56
-
56
+
57
57
with pytest.raises(InvalidHandleError, match="exceeds maximum length"):
58
58
Handle(handle_str)
59
59
60
60
def test_invalid_handle_no_dot_separator(self):
61
61
"""Test that a Handle without a dot separator raises InvalidHandleError."""
62
62
handle_str = "example"
63
-
63
+
64
64
with pytest.raises(InvalidHandleError, match="invalid format"):
65
65
Handle(handle_str)
66
66
67
67
def test_invalid_handle_starts_with_dot(self):
68
68
"""Test that a Handle starting with a dot raises InvalidHandleError."""
69
69
handle_str = ".example.com"
70
-
70
+
71
71
with pytest.raises(InvalidHandleError, match="invalid format"):
72
72
Handle(handle_str)
73
73
74
74
def test_invalid_handle_ends_with_dot(self):
75
75
"""Test that a Handle ending with a dot raises InvalidHandleError."""
76
76
handle_str = "example.com."
77
-
77
+
78
78
with pytest.raises(InvalidHandleError, match="invalid format"):
79
79
Handle(handle_str)
80
80
81
81
def test_invalid_handle_segment_too_long(self):
82
82
"""Test that a Handle with a segment that is too long raises InvalidHandleError."""
83
83
handle_str = f"{'a' * 64}.com"
84
-
84
+
85
85
with pytest.raises(InvalidHandleError, match="segment length error"):
86
86
Handle(handle_str)
87
87
88
88
def test_invalid_handle_segment_empty(self):
89
89
"""Test that a Handle with an empty segment raises InvalidHandleError."""
90
90
handle_str = "example..com"
91
-
91
+
92
92
with pytest.raises(InvalidHandleError, match="segment length error"):
93
93
Handle(handle_str)
94
94
95
95
def test_invalid_handle_invalid_characters(self):
96
96
"""Test that a Handle with invalid characters raises InvalidHandleError."""
97
97
handle_str = "ex@mple.com"
98
-
98
+
99
99
with pytest.raises(InvalidHandleError, match="contains invalid characters"):
100
100
Handle(handle_str)
101
101
102
102
def test_invalid_handle_segment_starts_with_hyphen(self):
103
103
"""Test that a Handle with a segment starting with a hyphen raises InvalidHandleError."""
104
104
handle_str = "-example.com"
105
-
105
+
106
106
with pytest.raises(InvalidHandleError, match="invalid format"):
107
107
Handle(handle_str)
108
108
109
109
def test_invalid_handle_segment_ends_with_hyphen(self):
110
110
"""Test that a Handle with a segment ending with a hyphen raises InvalidHandleError."""
111
111
handle_str = "example-.com"
112
-
112
+
113
113
with pytest.raises(InvalidHandleError, match="invalid format"):
114
114
Handle(handle_str)
115
115
116
116
def test_invalid_handle_tld_starts_with_digit(self):
117
117
"""Test that a Handle with a TLD starting with a digit raises InvalidHandleError."""
118
118
handle_str = "example.1com"
119
-
119
+
120
120
with pytest.raises(InvalidHandleError, match="invalid format"):
121
121
Handle(handle_str)
122
122
···
125
125
handle_str = "example.com"
126
126
handle1 = Handle(handle_str)
127
127
handle2 = Handle(handle_str)
128
-
128
+
129
129
assert handle1 == handle2
130
130
assert handle1 != "not a handle object"
131
131
···
133
133
"""Test Handle string representation."""
134
134
handle_str = "example.com"
135
135
handle = Handle(handle_str)
136
-
136
+
137
137
assert str(handle) == handle_str
138
138
139
139
def test_handle_case_insensitive_storage(self):
140
140
"""Test that Handle stores the handle in lowercase."""
141
141
handle_str = "ExAmPlE.CoM"
142
142
handle = Handle(handle_str)
143
-
143
+
144
144
# The handle should be stored in lowercase
145
145
assert handle.handle == "example.com"
146
146
# The string representation should also return the lowercase form
···
150
150
"""Test resolving a handle to DID using DNS method."""
151
151
handle_str = "bsky.app"
152
152
handle = Handle(handle_str)
153
-
153
+
154
154
# This test may fail if there's no internet connection or if DNS resolution fails
155
155
try:
156
156
did = handle.toTID()
···
164
164
"""Test resolving a handle to DID using HTTP method."""
165
165
handle_str = "blueskyweb.xyz"
166
166
handle = Handle(handle_str)
167
-
167
+
168
168
# This test may fail if there's no internet connection or if HTTP resolution fails
169
169
try:
170
170
did = handle.toTID()
···
178
178
"""Test resolving an unresolvable handle returns None."""
179
179
handle_str = "nonexistent-domain-12345.com"
180
180
handle = Handle(handle_str)
181
-
181
+
182
182
# This should return None for a non-existent domain
183
183
did = handle.toTID()
184
-
assert did is None
184
+
assert did is None
+39
-33
tests/uri/test_nsid.py
+39
-33
tests/uri/test_nsid.py
···
12
12
"""Test creating an NSID with a valid simple format."""
13
13
nsid_str = "com.example.recordName"
14
14
nsid = NSID(nsid_str)
15
-
15
+
16
16
assert str(nsid) == nsid_str
17
17
assert nsid.nsid == nsid_str
18
18
assert nsid.domainAuthority == ["com", "example"]
···
24
24
"""Test creating an NSID with a valid fragment."""
25
25
nsid_str = "com.example.recordName#fragment"
26
26
nsid = NSID(nsid_str)
27
-
27
+
28
28
assert str(nsid) == nsid_str
29
29
assert nsid.nsid == nsid_str
30
30
assert nsid.domainAuthority == ["com", "example"]
···
36
36
"""Test creating an NSID with multiple domain segments."""
37
37
nsid_str = "net.users.bob.ping"
38
38
nsid = NSID(nsid_str)
39
-
39
+
40
40
assert str(nsid) == nsid_str
41
41
assert nsid.nsid == nsid_str
42
42
assert nsid.domainAuthority == ["net", "users", "bob"]
···
48
48
"""Test creating an NSID with hyphens in domain segments."""
49
49
nsid_str = "a-0.b-1.c.recordName"
50
50
nsid = NSID(nsid_str)
51
-
51
+
52
52
assert str(nsid) == nsid_str
53
53
assert nsid.nsid == nsid_str
54
54
assert nsid.domainAuthority == ["a-0", "b-1", "c"]
···
60
60
"""Test creating an NSID with case-sensitive name."""
61
61
nsid_str = "com.example.fooBar"
62
62
nsid = NSID(nsid_str)
63
-
63
+
64
64
assert str(nsid) == nsid_str
65
65
assert nsid.nsid == nsid_str
66
66
assert nsid.domainAuthority == ["com", "example"]
···
72
72
"""Test creating an NSID with numbers in the name."""
73
73
nsid_str = "com.example.record123"
74
74
nsid = NSID(nsid_str)
75
-
75
+
76
76
assert str(nsid) == nsid_str
77
77
assert nsid.nsid == nsid_str
78
78
assert nsid.domainAuthority == ["com", "example"]
···
83
83
def test_invalid_nsid_non_ascii_characters(self):
84
84
"""Test that an NSID with non-ASCII characters raises InvalidNSIDError."""
85
85
nsid_str = "com.exa💩ple.thing"
86
-
86
+
87
87
with pytest.raises(InvalidNSIDError, match="contains invalid characters"):
88
88
NSID(nsid_str)
89
89
···
92
92
# Create an NSID that exceeds the 317 character limit
93
93
long_segment = "a" * 100
94
94
nsid_str = f"{long_segment}.{long_segment}.{long_segment}.recordName"
95
-
96
-
with pytest.raises(InvalidNSIDError, match="domain authority length exceeds limit"):
95
+
96
+
with pytest.raises(
97
+
InvalidNSIDError, match="domain authority length exceeds limit"
98
+
):
97
99
NSID(nsid_str)
98
100
99
101
def test_invalid_nsid_starts_with_dot(self):
100
102
"""Test that an NSID starting with a dot raises InvalidNSIDError."""
101
103
nsid_str = ".com.example.recordName"
102
-
104
+
103
105
with pytest.raises(InvalidNSIDError, match="invalid format"):
104
106
NSID(nsid_str)
105
107
106
108
def test_invalid_nsid_ends_with_dot(self):
107
109
"""Test that an NSID ending with a dot raises InvalidNSIDError."""
108
110
nsid_str = "com.example.recordName."
109
-
111
+
110
112
with pytest.raises(InvalidNSIDError, match="invalid format"):
111
113
NSID(nsid_str)
112
114
113
115
def test_invalid_nsid_too_few_segments(self):
114
116
"""Test that an NSID with too few segments raises InvalidNSIDError."""
115
117
nsid_str = "com.example"
116
-
118
+
117
119
with pytest.raises(InvalidNSIDError, match="invalid format"):
118
120
NSID(nsid_str)
119
121
···
121
123
"""Test that an NSID with domain authority that is too long raises InvalidNSIDError."""
122
124
# Create a domain authority that exceeds the 253 character limit
123
125
long_segment = "a" * 63
124
-
nsid_str = f"{long_segment}.{long_segment}.{long_segment}.{long_segment}.recordName"
125
-
126
-
with pytest.raises(InvalidNSIDError, match="domain authority length exceeds limit"):
126
+
nsid_str = (
127
+
f"{long_segment}.{long_segment}.{long_segment}.{long_segment}.recordName"
128
+
)
129
+
130
+
with pytest.raises(
131
+
InvalidNSIDError, match="domain authority length exceeds limit"
132
+
):
127
133
NSID(nsid_str)
128
134
129
135
def test_invalid_nsid_domain_segment_too_long(self):
130
136
"""Test that an NSID with a domain segment that is too long raises InvalidNSIDError."""
131
137
nsid_str = f"{'a' * 64}.example.recordName"
132
-
138
+
133
139
with pytest.raises(InvalidNSIDError, match="segment length error"):
134
140
NSID(nsid_str)
135
141
136
142
def test_invalid_nsid_domain_segment_empty(self):
137
143
"""Test that an NSID with an empty domain segment raises InvalidNSIDError."""
138
144
nsid_str = "com..example.recordName"
139
-
145
+
140
146
with pytest.raises(InvalidNSIDError, match="segment length error"):
141
147
NSID(nsid_str)
142
148
143
149
def test_invalid_nsid_domain_invalid_characters(self):
144
150
"""Test that an NSID with invalid characters in domain raises InvalidNSIDError."""
145
151
nsid_str = "com.ex@mple.recordName"
146
-
152
+
147
153
with pytest.raises(InvalidNSIDError, match="contains invalid characters"):
148
154
NSID(nsid_str)
149
155
150
156
def test_invalid_nsid_domain_segment_starts_with_hyphen(self):
151
157
"""Test that an NSID with a domain segment starting with a hyphen raises InvalidNSIDError."""
152
158
nsid_str = "com.-example.recordName"
153
-
159
+
154
160
with pytest.raises(InvalidNSIDError, match="invalid format"):
155
161
NSID(nsid_str)
156
162
157
163
def test_invalid_nsid_domain_segment_ends_with_hyphen(self):
158
164
"""Test that an NSID with a domain segment ending with a hyphen raises InvalidNSIDError."""
159
165
nsid_str = "com.example-.recordName"
160
-
166
+
161
167
with pytest.raises(InvalidNSIDError, match="invalid format"):
162
168
NSID(nsid_str)
163
169
164
170
def test_invalid_nsid_tld_starts_with_digit(self):
165
171
"""Test that an NSID with a TLD starting with a digit raises InvalidNSIDError."""
166
172
nsid_str = "1com.example.recordName"
167
-
173
+
168
174
with pytest.raises(InvalidNSIDError, match="invalid format"):
169
175
NSID(nsid_str)
170
176
171
177
def test_invalid_nsid_name_empty(self):
172
178
"""Test that an NSID with an empty name raises InvalidNSIDError."""
173
179
nsid_str = "com.example."
174
-
180
+
175
181
with pytest.raises(InvalidNSIDError, match="invalid format"):
176
182
NSID(nsid_str)
177
183
178
184
def test_invalid_nsid_name_too_long(self):
179
185
"""Test that an NSID with a name that is too long raises InvalidNSIDError."""
180
186
nsid_str = f"com.example.{'a' * 64}"
181
-
187
+
182
188
with pytest.raises(InvalidNSIDError, match="name length error"):
183
189
NSID(nsid_str)
184
190
185
191
def test_invalid_nsid_name_invalid_characters(self):
186
192
"""Test that an NSID with invalid characters in name raises InvalidNSIDError."""
187
193
nsid_str = "com.example.record-name"
188
-
194
+
189
195
with pytest.raises(InvalidNSIDError, match="contains invalid characters"):
190
196
NSID(nsid_str)
191
197
192
198
def test_invalid_nsid_name_starts_with_digit(self):
193
199
"""Test that an NSID with a name starting with a digit raises InvalidNSIDError."""
194
200
nsid_str = "com.example.1record"
195
-
201
+
196
202
with pytest.raises(InvalidNSIDError, match="invalid format"):
197
203
NSID(nsid_str)
198
204
199
205
def test_invalid_nsid_fragment_empty(self):
200
206
"""Test that an NSID with an empty fragment raises InvalidNSIDError."""
201
207
nsid_str = "com.example.recordName#"
202
-
208
+
203
209
with pytest.raises(InvalidNSIDError, match="fragment length error"):
204
210
NSID(nsid_str)
205
211
206
212
def test_invalid_nsid_fragment_too_long(self):
207
213
"""Test that an NSID with a fragment that is too long raises InvalidNSIDError."""
208
214
nsid_str = f"com.example.recordName#{'a' * 64}"
209
-
215
+
210
216
with pytest.raises(InvalidNSIDError, match="fragment length error"):
211
217
NSID(nsid_str)
212
218
213
219
def test_invalid_nsid_fragment_invalid_characters(self):
214
220
"""Test that an NSID with invalid characters in fragment raises InvalidNSIDError."""
215
221
nsid_str = "com.example.recordName#fragment-with-hyphen"
216
-
222
+
217
223
with pytest.raises(InvalidNSIDError, match="contains invalid characters"):
218
224
NSID(nsid_str)
219
225
220
226
def test_invalid_nsid_fragment_starts_with_digit(self):
221
227
"""Test that an NSID with a fragment starting with a digit raises InvalidNSIDError."""
222
228
nsid_str = "com.example.recordName#1fragment"
223
-
229
+
224
230
with pytest.raises(InvalidNSIDError, match="invalid format"):
225
231
NSID(nsid_str)
226
232
···
229
235
nsid_str = "com.example.recordName"
230
236
nsid1 = NSID(nsid_str)
231
237
nsid2 = NSID(nsid_str)
232
-
238
+
233
239
assert nsid1 == nsid2
234
240
assert nsid1 != "not an nsid object"
235
241
···
237
243
"""Test NSID string representation."""
238
244
nsid_str = "com.example.recordName"
239
245
nsid = NSID(nsid_str)
240
-
246
+
241
247
assert str(nsid) == nsid_str
242
248
243
249
def test_nsid_string_representation_with_fragment(self):
244
250
"""Test NSID string representation with fragment."""
245
251
nsid_str = "com.example.recordName#fragment"
246
252
nsid = NSID(nsid_str)
247
-
248
-
assert str(nsid) == nsid_str
253
+
254
+
assert str(nsid) == nsid_str
+27
-17
tests/uri/test_restricted_uri.py
+27
-17
tests/uri/test_restricted_uri.py
···
10
10
11
11
def test_valid_restricted_uri_with_did_collection_and_rkey(self):
12
12
"""Test creating a RestrictedURI with a valid DID, collection, and rkey."""
13
-
uri_str = "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jwdwj2ctlk26"
13
+
uri_str = (
14
+
"at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jwdwj2ctlk26"
15
+
)
14
16
uri = RestrictedURI(uri_str)
15
-
17
+
16
18
assert str(uri) == uri_str
17
19
assert uri.authorityAsText == "did:plc:z72i7hdynmk6r22z27h6tvur"
18
20
assert uri.path == ["app.bsky.feed.post", "3jwdwj2ctlk26"]
···
26
28
"""Test creating a RestrictedURI with a valid handle, collection, and rkey."""
27
29
uri_str = "at://bnewbold.bsky.team/app.bsky.feed.post/3jwdwj2ctlk26"
28
30
uri = RestrictedURI(uri_str)
29
-
31
+
30
32
assert str(uri) == uri_str
31
33
assert uri.authorityAsText == "bnewbold.bsky.team"
32
34
assert uri.path == ["app.bsky.feed.post", "3jwdwj2ctlk26"]
···
39
41
"""Test creating a RestrictedURI with only a collection."""
40
42
uri_str = "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post"
41
43
uri = RestrictedURI(uri_str)
42
-
44
+
43
45
assert str(uri) == uri_str
44
46
assert uri.authorityAsText == "did:plc:z72i7hdynmk6r22z27h6tvur"
45
47
assert uri.path == ["app.bsky.feed.post"]
···
51
53
"""Test creating a RestrictedURI with only an authority."""
52
54
uri_str = "at://did:plc:z72i7hdynmk6r22z27h6tvur"
53
55
uri = RestrictedURI(uri_str)
54
-
56
+
55
57
assert str(uri) == uri_str
56
58
assert uri.authorityAsText == "did:plc:z72i7hdynmk6r22z27h6tvur"
57
59
assert uri.path == []
···
60
62
61
63
def test_invalid_restricted_uri_with_query(self):
62
64
"""Test that a RestrictedURI with query parameters raises InvalidRestrictedURIError."""
63
-
uri_str = "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post?param1=value1"
64
-
65
-
with pytest.raises(InvalidRestrictedURIError, match="query parameters not supported"):
65
+
uri_str = (
66
+
"at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post?param1=value1"
67
+
)
68
+
69
+
with pytest.raises(
70
+
InvalidRestrictedURIError, match="query parameters not supported"
71
+
):
66
72
RestrictedURI(uri_str)
67
73
68
74
def test_invalid_restricted_uri_with_fragment(self):
69
75
"""Test that a RestrictedURI with a fragment raises InvalidRestrictedURIError."""
70
76
uri_str = "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jwdwj2ctlk26#$.some.json.path"
71
-
77
+
72
78
with pytest.raises(InvalidRestrictedURIError, match="fragments not supported"):
73
79
RestrictedURI(uri_str)
74
80
75
81
def test_invalid_restricted_uri_with_invalid_authority(self):
76
82
"""Test that a RestrictedURI with invalid authority raises InvalidRestrictedURIError."""
77
83
uri_str = "at://invalid_authority/app.bsky.feed.post/3jwdwj2ctlk26"
78
-
84
+
79
85
with pytest.raises(InvalidRestrictedURIError, match="invalid authority"):
80
86
RestrictedURI(uri_str)
81
87
82
88
def test_invalid_restricted_uri_too_many_path_segments(self):
83
89
"""Test that a RestrictedURI with too many path segments raises InvalidRestrictedURIError."""
84
90
uri_str = "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jwdwj2ctlk26/extra"
85
-
91
+
86
92
with pytest.raises(InvalidRestrictedURIError, match="too many path segments"):
87
93
RestrictedURI(uri_str)
88
94
89
95
def test_invalid_restricted_uri_base_uri_validation_failure(self):
90
96
"""Test that a RestrictedURI with invalid base URI raises InvalidURIError."""
91
97
uri_str = "https://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post"
92
-
98
+
93
99
with pytest.raises(InvalidURIError, match="invalid format"):
94
100
RestrictedURI(uri_str)
95
101
96
102
def test_restricted_uri_equality(self):
97
103
"""Test RestrictedURI equality comparison."""
98
-
uri_str = "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jwdwj2ctlk26"
104
+
uri_str = (
105
+
"at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jwdwj2ctlk26"
106
+
)
99
107
uri1 = RestrictedURI(uri_str)
100
108
uri2 = RestrictedURI(uri_str)
101
-
109
+
102
110
assert uri1 == uri2
103
111
assert uri1 != "not a uri object"
104
112
105
113
def test_restricted_uri_string_representation(self):
106
114
"""Test RestrictedURI string representation."""
107
-
uri_str = "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jwdwj2ctlk26"
115
+
uri_str = (
116
+
"at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jwdwj2ctlk26"
117
+
)
108
118
uri = RestrictedURI(uri_str)
109
-
110
-
assert str(uri) == uri_str
119
+
120
+
assert str(uri) == uri_str
+39
-39
tests/uri/test_rkey.py
+39
-39
tests/uri/test_rkey.py
···
13
13
"""Test creating an RKey with a valid simple format."""
14
14
rkey_str = "3jui7kd54zh2y"
15
15
rkey = RKey(rkey_str)
16
-
16
+
17
17
assert str(rkey) == rkey_str
18
18
assert rkey.recordKey == rkey_str
19
19
···
21
21
"""Test creating an RKey with various valid characters."""
22
22
rkey_str = "example.com"
23
23
rkey = RKey(rkey_str)
24
-
24
+
25
25
assert str(rkey) == rkey_str
26
26
assert rkey.recordKey == rkey_str
27
27
···
29
29
"""Test creating an RKey with valid special characters."""
30
30
rkey_str = "~1.2-3_"
31
31
rkey = RKey(rkey_str)
32
-
32
+
33
33
assert str(rkey) == rkey_str
34
34
assert rkey.recordKey == rkey_str
35
35
···
37
37
"""Test creating an RKey with a colon."""
38
38
rkey_str = "pre:fix"
39
39
rkey = RKey(rkey_str)
40
-
40
+
41
41
assert str(rkey) == rkey_str
42
42
assert rkey.recordKey == rkey_str
43
43
···
45
45
"""Test creating an RKey with just an underscore."""
46
46
rkey_str = "_"
47
47
rkey = RKey(rkey_str)
48
-
48
+
49
49
assert str(rkey) == rkey_str
50
50
assert rkey.recordKey == rkey_str
51
51
52
52
def test_invalid_rkey_empty(self):
53
53
"""Test that an empty RKey raises InvalidRKeyError."""
54
54
rkey_str = ""
55
-
55
+
56
56
with pytest.raises(InvalidRKeyError, match="record key is empty"):
57
57
RKey(rkey_str)
58
58
···
60
60
"""Test that an RKey that is too long raises InvalidRKeyError."""
61
61
# Create an RKey that exceeds the 512 character limit
62
62
rkey_str = "a" * 513
63
-
63
+
64
64
with pytest.raises(InvalidRKeyError, match="exceeds maximum length"):
65
65
RKey(rkey_str)
66
66
67
67
def test_invalid_rkey_reserved_double_dot(self):
68
68
"""Test that an RKey with '..' raises InvalidRKeyError."""
69
69
rkey_str = ".."
70
-
70
+
71
71
with pytest.raises(InvalidRKeyError, match="reserved value"):
72
72
RKey(rkey_str)
73
73
74
74
def test_invalid_rkey_reserved_single_dot(self):
75
75
"""Test that an RKey with '.' raises InvalidRKeyError."""
76
76
rkey_str = "."
77
-
77
+
78
78
with pytest.raises(InvalidRKeyError, match="reserved value"):
79
79
RKey(rkey_str)
80
80
81
81
def test_invalid_rkey_invalid_characters(self):
82
82
"""Test that an RKey with invalid characters raises InvalidRKeyError."""
83
83
rkey_str = "alpha/beta"
84
-
84
+
85
85
with pytest.raises(InvalidRKeyError, match="contains invalid characters"):
86
86
RKey(rkey_str)
87
87
88
88
def test_invalid_rkey_hash_character(self):
89
89
"""Test that an RKey with a hash character raises InvalidRKeyError."""
90
90
rkey_str = "#extra"
91
-
91
+
92
92
with pytest.raises(InvalidRKeyError, match="contains invalid characters"):
93
93
RKey(rkey_str)
94
94
95
95
def test_invalid_rkey_at_character(self):
96
96
"""Test that an RKey with an at character raises InvalidRKeyError."""
97
97
rkey_str = "@handle"
98
-
98
+
99
99
with pytest.raises(InvalidRKeyError, match="contains invalid characters"):
100
100
RKey(rkey_str)
101
101
102
102
def test_invalid_rkey_space(self):
103
103
"""Test that an RKey with a space raises InvalidRKeyError."""
104
104
rkey_str = "any space"
105
-
105
+
106
106
with pytest.raises(InvalidRKeyError, match="contains invalid characters"):
107
107
RKey(rkey_str)
108
108
109
109
def test_invalid_rkey_plus_character(self):
110
110
"""Test that an RKey with a plus character raises InvalidRKeyError."""
111
111
rkey_str = "any+space"
112
-
112
+
113
113
with pytest.raises(InvalidRKeyError, match="contains invalid characters"):
114
114
RKey(rkey_str)
115
115
116
116
def test_invalid_rkey_brackets(self):
117
117
"""Test that an RKey with brackets raises InvalidRKeyError."""
118
118
rkey_str = "number[3]"
119
-
119
+
120
120
with pytest.raises(InvalidRKeyError, match="contains invalid characters"):
121
121
RKey(rkey_str)
122
122
123
123
def test_invalid_rkey_parentheses(self):
124
124
"""Test that an RKey with parentheses raises InvalidRKeyError."""
125
125
rkey_str = "number(3)"
126
-
126
+
127
127
with pytest.raises(InvalidRKeyError, match="contains invalid characters"):
128
128
RKey(rkey_str)
129
129
130
130
def test_invalid_rkey_quotes(self):
131
131
"""Test that an RKey with quotes raises InvalidRKeyError."""
132
132
rkey_str = '"quote"'
133
-
133
+
134
134
with pytest.raises(InvalidRKeyError, match="contains invalid characters"):
135
135
RKey(rkey_str)
136
136
137
137
def test_invalid_rkey_base64_padding(self):
138
138
"""Test that an RKey with base64 padding raises InvalidRKeyError."""
139
139
rkey_str = "dHJ1ZQ=="
140
-
140
+
141
141
with pytest.raises(InvalidRKeyError, match="contains invalid characters"):
142
142
RKey(rkey_str)
143
143
···
146
146
rkey_str = "3jui7kd54zh2y"
147
147
rkey1 = RKey(rkey_str)
148
148
rkey2 = RKey(rkey_str)
149
-
149
+
150
150
assert rkey1 == rkey2
151
151
assert rkey1 != "not an rkey object"
152
152
···
154
154
"""Test RKey string representation."""
155
155
rkey_str = "3jui7kd54zh2y"
156
156
rkey = RKey(rkey_str)
157
-
157
+
158
158
assert str(rkey) == rkey_str
159
159
160
160
···
164
164
def test_tid_creation_default(self):
165
165
"""Test creating a TID with default parameters."""
166
166
tid = TID()
167
-
167
+
168
168
assert isinstance(tid, TID)
169
169
assert isinstance(tid, RKey)
170
170
assert isinstance(tid.timestamp, datetime.datetime)
···
176
176
"""Test creating a TID with a specific timestamp."""
177
177
timestamp = datetime.datetime(2023, 1, 1, 12, 0, 0)
178
178
tid = TID(time=timestamp)
179
-
179
+
180
180
assert tid.timestamp == timestamp
181
181
assert isinstance(tid.clockIdentifier, int)
182
182
assert 0 <= tid.clockIdentifier < 1024
···
185
185
"""Test creating a TID with a specific clock identifier."""
186
186
clock_id = 42
187
187
tid = TID(clockIdentifier=clock_id)
188
-
188
+
189
189
assert tid.clockIdentifier == clock_id
190
190
assert isinstance(tid.timestamp, datetime.datetime)
191
191
···
194
194
timestamp = datetime.datetime(2023, 1, 1, 12, 0, 0)
195
195
clock_id = 42
196
196
tid = TID(time=timestamp, clockIdentifier=clock_id)
197
-
197
+
198
198
assert tid.timestamp == timestamp
199
199
assert tid.clockIdentifier == clock_id
200
200
···
203
203
timestamp = datetime.datetime(2023, 1, 1, 12, 0, 0)
204
204
clock_id = 42
205
205
tid = TID(time=timestamp, clockIdentifier=clock_id)
206
-
206
+
207
207
int_value = int(tid)
208
208
expected_value = int(timestamp.timestamp() * 1000000) * 1024 + clock_id
209
-
209
+
210
210
assert int_value == expected_value
211
211
212
212
def test_tid_string_representation(self):
213
213
"""Test TID string representation."""
214
214
tid = TID()
215
-
215
+
216
216
str_value = str(tid)
217
217
assert len(str_value) == 13
218
218
assert all(c in "234567abcdefghijklmnopqrstuvwxyz" for c in str_value)
···
223
223
clock_id = 42
224
224
tid1 = TID(time=timestamp, clockIdentifier=clock_id)
225
225
tid2 = TID(time=timestamp, clockIdentifier=clock_id)
226
-
226
+
227
227
assert tid1 == tid2
228
228
229
229
def test_tid_equality_with_rkey(self):
···
232
232
clock_id = 42
233
233
tid = TID(time=timestamp, clockIdentifier=clock_id)
234
234
rkey = RKey(str(tid))
235
-
235
+
236
236
assert tid == rkey
237
237
238
238
def test_tid_inequality_with_different_object(self):
239
239
"""Test TID inequality comparison with a different object type."""
240
240
tid = TID()
241
-
241
+
242
242
assert tid != "not a tid object"
243
243
244
244
def test_tid_inequality_with_different_timestamp(self):
···
248
248
clock_id = 42
249
249
tid1 = TID(time=timestamp1, clockIdentifier=clock_id)
250
250
tid2 = TID(time=timestamp2, clockIdentifier=clock_id)
251
-
251
+
252
252
assert tid1 != tid2
253
253
254
254
def test_tid_inequality_with_different_clock_id(self):
···
258
258
clock_id2 = 43
259
259
tid1 = TID(time=timestamp, clockIdentifier=clock_id1)
260
260
tid2 = TID(time=timestamp, clockIdentifier=clock_id2)
261
-
261
+
262
262
assert tid1 != tid2
263
263
264
264
···
268
268
def test_import_tid_from_integer_default(self):
269
269
"""Test importing a TID from integer with default value."""
270
270
tid = importTIDfromInteger()
271
-
271
+
272
272
assert isinstance(tid, TID)
273
273
assert isinstance(tid.timestamp, datetime.datetime)
274
274
assert isinstance(tid.clockIdentifier, int)
···
280
280
clock_id = 42
281
281
original_tid = TID(time=timestamp, clockIdentifier=clock_id)
282
282
int_value = int(original_tid)
283
-
283
+
284
284
imported_tid = importTIDfromInteger(int_value)
285
-
285
+
286
286
assert imported_tid.timestamp == timestamp
287
287
assert imported_tid.clockIdentifier == clock_id
288
288
289
289
def test_import_tid_from_base32_default(self):
290
290
"""Test importing a TID from base32 with default value."""
291
291
tid = importTIDfromBase32()
292
-
292
+
293
293
assert isinstance(tid, TID)
294
294
assert isinstance(tid.timestamp, datetime.datetime)
295
295
assert isinstance(tid.clockIdentifier, int)
···
299
299
"""Test importing a TID from base32 with a specific value."""
300
300
original_tid = TID()
301
301
str_value = str(original_tid)
302
-
302
+
303
303
imported_tid = importTIDfromBase32(str_value)
304
-
305
-
assert int(imported_tid) == int(original_tid)
304
+
305
+
assert int(imported_tid) == int(original_tid)
+26
-18
tests/uri/test_uri.py
+26
-18
tests/uri/test_uri.py
···
10
10
11
11
def test_valid_uri_with_did(self):
12
12
"""Test creating a URI with a valid DID."""
13
-
uri_str = "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jwdwj2ctlk26"
13
+
uri_str = (
14
+
"at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jwdwj2ctlk26"
15
+
)
14
16
uri = URI(uri_str)
15
-
17
+
16
18
assert str(uri) == uri_str
17
19
assert uri.authorityAsText == "did:plc:z72i7hdynmk6r22z27h6tvur"
18
20
assert uri.path == ["app.bsky.feed.post", "3jwdwj2ctlk26"]
···
26
28
"""Test creating a URI with a valid handle."""
27
29
uri_str = "at://bnewbold.bsky.team/app.bsky.feed.post/3jwdwj2ctlk26"
28
30
uri = URI(uri_str)
29
-
31
+
30
32
assert str(uri) == uri_str
31
33
assert uri.authorityAsText == "bnewbold.bsky.team"
32
34
assert uri.path == ["app.bsky.feed.post", "3jwdwj2ctlk26"]
···
36
38
"""Test creating a URI with only a collection."""
37
39
uri_str = "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post"
38
40
uri = URI(uri_str)
39
-
41
+
40
42
assert str(uri) == uri_str
41
43
assert uri.authorityAsText == "did:plc:z72i7hdynmk6r22z27h6tvur"
42
44
assert uri.path == ["app.bsky.feed.post"]
···
46
48
"""Test creating a URI with only an authority."""
47
49
uri_str = "at://did:plc:z72i7hdynmk6r22z27h6tvur"
48
50
uri = URI(uri_str)
49
-
51
+
50
52
assert str(uri) == uri_str
51
53
assert uri.authorityAsText == "did:plc:z72i7hdynmk6r22z27h6tvur"
52
54
assert uri.path == []
···
56
58
"""Test creating a URI with query parameters."""
57
59
uri_str = "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post?param1=value1¶m2=value2"
58
60
uri = URI(uri_str)
59
-
61
+
60
62
assert uri.query == {"param1": ["value1"], "param2": ["value2"]}
61
63
assert uri.queryAsText == "param1%3Dvalue1%26param2%3Dvalue2"
62
64
···
64
66
"""Test creating a URI with a fragment."""
65
67
uri_str = "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jwdwj2ctlk26#$.some.json.path"
66
68
uri = URI(uri_str)
67
-
69
+
68
70
assert uri.fragment is not None
69
71
assert uri.fragmentAsText == "%24.some.json.path"
70
72
71
73
def test_invalid_uri_non_ascii_characters(self):
72
74
"""Test that non-ASCII characters in URI raise InvalidURIError."""
73
75
uri_str = "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/💩"
74
-
76
+
75
77
with pytest.raises(InvalidURIError, match="contains invalid characters"):
76
78
URI(uri_str)
77
79
···
80
82
# Create a URI that exceeds the 8000 character limit
81
83
long_path = "a" * 8000
82
84
uri_str = f"at://did:plc:z72i7hdynmk6r22z27h6tvur/{long_path}"
83
-
85
+
84
86
with pytest.raises(InvalidURIError, match="exceeds maximum length"):
85
87
URI(uri_str)
86
88
87
89
def test_invalid_uri_wrong_scheme(self):
88
90
"""Test that a URI with wrong scheme raises InvalidURIError."""
89
-
uri_str = "https://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jwdwj2ctlk26"
90
-
91
+
uri_str = (
92
+
"https://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jwdwj2ctlk26"
93
+
)
94
+
91
95
with pytest.raises(InvalidURIError, match="invalid format"):
92
96
URI(uri_str)
93
97
94
98
def test_invalid_uri_trailing_slash(self):
95
99
"""Test that a URI with trailing slash raises InvalidURIError."""
96
100
uri_str = "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/"
97
-
101
+
98
102
with pytest.raises(InvalidURIError, match="cannot end with a slash"):
99
103
URI(uri_str)
100
104
101
105
def test_invalid_uri_with_userinfo(self):
102
106
"""Test that a URI with userinfo raises InvalidURIError."""
103
107
uri_str = "at://user:pass@did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post"
104
-
108
+
105
109
with pytest.raises(InvalidURIError, match="does not support user information"):
106
110
URI(uri_str)
107
111
108
112
def test_uri_equality(self):
109
113
"""Test URI equality comparison."""
110
-
uri_str = "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jwdwj2ctlk26"
114
+
uri_str = (
115
+
"at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jwdwj2ctlk26"
116
+
)
111
117
uri1 = URI(uri_str)
112
118
uri2 = URI(uri_str)
113
-
119
+
114
120
assert uri1 == uri2
115
121
assert uri1 != "not a uri object"
116
122
117
123
def test_uri_string_representation(self):
118
124
"""Test URI string representation."""
119
-
uri_str = "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jwdwj2ctlk26"
125
+
uri_str = (
126
+
"at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jwdwj2ctlk26"
127
+
)
120
128
uri = URI(uri_str)
121
-
122
-
assert str(uri) == uri_str
129
+
130
+
assert str(uri) == uri_str