twitwi 0.21.2__py3-none-any.whl → 0.22.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- test/bluesky/formatters_test.py +48 -1
- test/bluesky/normalizers_test.py +37 -1
- twitwi/bluesky/__init__.py +10 -1
- twitwi/bluesky/constants.py +3 -1
- twitwi/bluesky/formatters.py +9 -0
- twitwi/bluesky/normalizers.py +76 -22
- twitwi/bluesky/types.py +16 -2
- twitwi/bluesky/utils.py +33 -14
- twitwi/utils.py +28 -2
- {twitwi-0.21.2.dist-info → twitwi-0.22.1.dist-info}/METADATA +34 -3
- twitwi-0.22.1.dist-info/RECORD +22 -0
- twitwi-0.21.2.dist-info/RECORD +0 -22
- {twitwi-0.21.2.dist-info → twitwi-0.22.1.dist-info}/WHEEL +0 -0
- {twitwi-0.21.2.dist-info → twitwi-0.22.1.dist-info}/licenses/LICENSE.txt +0 -0
- {twitwi-0.21.2.dist-info → twitwi-0.22.1.dist-info}/top_level.txt +0 -0
- {twitwi-0.21.2.dist-info → twitwi-0.22.1.dist-info}/zip-safe +0 -0
test/bluesky/formatters_test.py
CHANGED
|
@@ -2,11 +2,13 @@ import csv
|
|
|
2
2
|
from io import StringIO
|
|
3
3
|
from twitwi.bluesky import (
|
|
4
4
|
format_profile_as_csv_row,
|
|
5
|
+
format_partial_profile_as_csv_row,
|
|
5
6
|
format_post_as_csv_row,
|
|
6
7
|
transform_profile_into_csv_dict,
|
|
8
|
+
transform_partial_profile_into_csv_dict,
|
|
7
9
|
transform_post_into_csv_dict,
|
|
8
10
|
)
|
|
9
|
-
from twitwi.bluesky.constants import PROFILE_FIELDS, POST_FIELDS
|
|
11
|
+
from twitwi.bluesky.constants import PROFILE_FIELDS, PARTIAL_PROFILE_FIELDS, POST_FIELDS
|
|
10
12
|
from test.utils import get_json_resource, open_resource
|
|
11
13
|
|
|
12
14
|
|
|
@@ -56,6 +58,51 @@ class TestFormatters:
|
|
|
56
58
|
buffer.seek(0)
|
|
57
59
|
assert list(csv.DictReader(buffer)) == list(csv.DictReader(f))
|
|
58
60
|
|
|
61
|
+
def test_format_partial_profile_as_csv_row(self):
|
|
62
|
+
normalized_partial_profiles = get_json_resource(
|
|
63
|
+
"bluesky-normalized-partial-profiles.json"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
buffer = StringIO(newline=None)
|
|
67
|
+
writer = csv.writer(buffer, quoting=csv.QUOTE_MINIMAL)
|
|
68
|
+
writer.writerow(PARTIAL_PROFILE_FIELDS)
|
|
69
|
+
|
|
70
|
+
for profile in normalized_partial_profiles:
|
|
71
|
+
writer.writerow(format_partial_profile_as_csv_row(profile))
|
|
72
|
+
|
|
73
|
+
if OVERWRITE_TESTS:
|
|
74
|
+
written = buffer.getvalue()
|
|
75
|
+
|
|
76
|
+
with open("test/resources/bluesky-partial-profiles-export.csv", "w") as f:
|
|
77
|
+
f.write(written)
|
|
78
|
+
|
|
79
|
+
with open_resource("bluesky-partial-profiles-export.csv") as f:
|
|
80
|
+
buffer.seek(0)
|
|
81
|
+
assert list(csv.reader(buffer)) == list(csv.reader(f))
|
|
82
|
+
|
|
83
|
+
def test_transform_partial_profile_into_csv_dict(self):
|
|
84
|
+
normalized_partial_profiles = get_json_resource(
|
|
85
|
+
"bluesky-normalized-partial-profiles.json"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
buffer = StringIO(newline=None)
|
|
89
|
+
writer = csv.DictWriter(
|
|
90
|
+
buffer,
|
|
91
|
+
fieldnames=PARTIAL_PROFILE_FIELDS,
|
|
92
|
+
extrasaction="ignore",
|
|
93
|
+
restval="",
|
|
94
|
+
quoting=csv.QUOTE_MINIMAL,
|
|
95
|
+
)
|
|
96
|
+
writer.writeheader()
|
|
97
|
+
|
|
98
|
+
for profile in normalized_partial_profiles:
|
|
99
|
+
transform_partial_profile_into_csv_dict(profile)
|
|
100
|
+
writer.writerow(profile)
|
|
101
|
+
|
|
102
|
+
with open_resource("bluesky-partial-profiles-export.csv") as f:
|
|
103
|
+
buffer.seek(0)
|
|
104
|
+
assert list(csv.DictReader(buffer)) == list(csv.DictReader(f))
|
|
105
|
+
|
|
59
106
|
def test_format_post_as_csv_row(self):
|
|
60
107
|
normalized_posts = get_json_resource("bluesky-normalized-posts.json")
|
|
61
108
|
|
test/bluesky/normalizers_test.py
CHANGED
|
@@ -5,7 +5,7 @@ from functools import partial
|
|
|
5
5
|
from pytz import timezone
|
|
6
6
|
from copy import deepcopy
|
|
7
7
|
|
|
8
|
-
from twitwi.bluesky import normalize_profile, normalize_post
|
|
8
|
+
from twitwi.bluesky import normalize_profile, normalize_partial_profile, normalize_post
|
|
9
9
|
|
|
10
10
|
from test.utils import get_json_resource
|
|
11
11
|
|
|
@@ -74,6 +74,42 @@ class TestNormalizers:
|
|
|
74
74
|
|
|
75
75
|
assert profile == original_arg
|
|
76
76
|
|
|
77
|
+
def test_normalize_partial_profile(self):
|
|
78
|
+
tz = timezone("Europe/Paris")
|
|
79
|
+
|
|
80
|
+
profiles = get_json_resource("bluesky-partial-profiles.json")
|
|
81
|
+
fn = partial(normalize_partial_profile, locale=tz)
|
|
82
|
+
|
|
83
|
+
if OVERWRITE_TESTS:
|
|
84
|
+
from test.utils import dump_json_resource
|
|
85
|
+
|
|
86
|
+
normalized_profiles = [
|
|
87
|
+
set_fake_collection_time(fn(profile)) for profile in profiles
|
|
88
|
+
]
|
|
89
|
+
dump_json_resource(
|
|
90
|
+
normalized_profiles, "bluesky-normalized-partial-profiles.json"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
expected = get_json_resource("bluesky-normalized-partial-profiles.json")
|
|
94
|
+
|
|
95
|
+
for idx, profile in enumerate(profiles):
|
|
96
|
+
result = fn(profile)
|
|
97
|
+
assert isinstance(result, dict)
|
|
98
|
+
assert "collection_time" in result and isinstance(
|
|
99
|
+
result["collection_time"], str
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
compare_dicts(profile["handle"], result, expected[idx])
|
|
103
|
+
|
|
104
|
+
def test_normalize_partial_profile_should_not_mutate(self):
|
|
105
|
+
profile = get_json_resource("bluesky-partial-profiles.json")[0]
|
|
106
|
+
|
|
107
|
+
original_arg = deepcopy(profile)
|
|
108
|
+
|
|
109
|
+
normalize_partial_profile(profile)
|
|
110
|
+
|
|
111
|
+
assert profile == original_arg
|
|
112
|
+
|
|
77
113
|
def test_normalize_post(self):
|
|
78
114
|
tz = timezone("Europe/Paris")
|
|
79
115
|
|
twitwi/bluesky/__init__.py
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
from twitwi.bluesky.normalizers import
|
|
1
|
+
from twitwi.bluesky.normalizers import (
|
|
2
|
+
normalize_profile,
|
|
3
|
+
normalize_partial_profile,
|
|
4
|
+
normalize_post,
|
|
5
|
+
)
|
|
2
6
|
from twitwi.bluesky.formatters import (
|
|
3
7
|
transform_profile_into_csv_dict,
|
|
4
8
|
format_profile_as_csv_row,
|
|
9
|
+
transform_partial_profile_into_csv_dict,
|
|
10
|
+
format_partial_profile_as_csv_row,
|
|
5
11
|
transform_post_into_csv_dict,
|
|
6
12
|
format_post_as_csv_row,
|
|
7
13
|
)
|
|
@@ -9,8 +15,11 @@ from twitwi.bluesky.formatters import (
|
|
|
9
15
|
__all__ = [
|
|
10
16
|
"transform_profile_into_csv_dict",
|
|
11
17
|
"format_profile_as_csv_row",
|
|
18
|
+
"transform_partial_profile_into_csv_dict",
|
|
19
|
+
"format_partial_profile_as_csv_row",
|
|
12
20
|
"transform_post_into_csv_dict",
|
|
13
21
|
"format_post_as_csv_row",
|
|
14
22
|
"normalize_profile",
|
|
23
|
+
"normalize_partial_profile",
|
|
15
24
|
"normalize_post",
|
|
16
25
|
]
|
twitwi/bluesky/constants.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
from typing import List, Optional
|
|
2
2
|
|
|
3
|
-
from twitwi.bluesky.types import BlueskyProfile, BlueskyPost
|
|
3
|
+
from twitwi.bluesky.types import BlueskyProfile, BlueskyPartialProfile, BlueskyPost
|
|
4
4
|
|
|
5
5
|
PROFILE_FIELDS = list(BlueskyProfile.__annotations__.keys())
|
|
6
6
|
|
|
7
|
+
PARTIAL_PROFILE_FIELDS = list(BlueskyPartialProfile.__annotations__.keys())
|
|
8
|
+
|
|
7
9
|
POST_FIELDS = list(BlueskyPost.__annotations__.keys())
|
|
8
10
|
|
|
9
11
|
POST_PLURAL_FIELDS = [
|
twitwi/bluesky/formatters.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from twitwi.formatters import make_transform_into_csv_dict, make_format_as_csv_row
|
|
2
2
|
from twitwi.bluesky.constants import (
|
|
3
3
|
PROFILE_FIELDS,
|
|
4
|
+
PARTIAL_PROFILE_FIELDS,
|
|
4
5
|
POST_FIELDS,
|
|
5
6
|
POST_PLURAL_FIELDS,
|
|
6
7
|
POST_BOOLEAN_FIELDS,
|
|
@@ -20,10 +21,18 @@ transform_profile_into_csv_dict = make_transform_into_csv_dict([], [])
|
|
|
20
21
|
|
|
21
22
|
format_profile_as_csv_row = make_format_as_csv_row(PROFILE_FIELDS, [], [])
|
|
22
23
|
|
|
24
|
+
transform_partial_profile_into_csv_dict = make_transform_into_csv_dict([], [])
|
|
25
|
+
|
|
26
|
+
format_partial_profile_as_csv_row = make_format_as_csv_row(
|
|
27
|
+
PARTIAL_PROFILE_FIELDS, [], []
|
|
28
|
+
)
|
|
29
|
+
|
|
23
30
|
|
|
24
31
|
__all__ = [
|
|
25
32
|
"transform_post_into_csv_dict",
|
|
26
33
|
"format_post_as_csv_row",
|
|
27
34
|
"transform_profile_into_csv_dict",
|
|
28
35
|
"format_profile_as_csv_row",
|
|
36
|
+
"transform_partial_profile_into_csv_dict",
|
|
37
|
+
"format_partial_profile_as_csv_row",
|
|
29
38
|
]
|
twitwi/bluesky/normalizers.py
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
from copy import deepcopy
|
|
2
|
-
from typing import List, Dict, Union, Optional, Literal, overload
|
|
2
|
+
from typing import List, Dict, Union, Optional, Literal, Any, overload
|
|
3
|
+
|
|
4
|
+
from ural import is_url
|
|
3
5
|
|
|
4
6
|
from twitwi.exceptions import BlueskyPayloadError
|
|
5
7
|
from twitwi.utils import (
|
|
6
8
|
get_collection_time,
|
|
7
9
|
get_dates,
|
|
8
|
-
|
|
10
|
+
safe_normalize_url,
|
|
9
11
|
custom_get_normalized_hostname,
|
|
10
12
|
)
|
|
11
13
|
from twitwi.bluesky.utils import (
|
|
@@ -18,10 +20,10 @@ from twitwi.bluesky.utils import (
|
|
|
18
20
|
format_starterpack_url,
|
|
19
21
|
format_media_url,
|
|
20
22
|
)
|
|
21
|
-
from twitwi.bluesky.types import BlueskyProfile, BlueskyPost
|
|
23
|
+
from twitwi.bluesky.types import BlueskyProfile, BlueskyPartialProfile, BlueskyPost
|
|
22
24
|
|
|
23
25
|
|
|
24
|
-
def normalize_profile(data: Dict, locale: Optional[
|
|
26
|
+
def normalize_profile(data: Dict, locale: Optional[Any] = None) -> BlueskyProfile:
|
|
25
27
|
associated = data["associated"]
|
|
26
28
|
|
|
27
29
|
pinned_post_uri = None
|
|
@@ -38,23 +40,48 @@ def normalize_profile(data: Dict, locale: Optional[str] = None) -> BlueskyProfil
|
|
|
38
40
|
"did": data["did"],
|
|
39
41
|
"url": format_profile_url(data["handle"]),
|
|
40
42
|
"handle": data["handle"],
|
|
41
|
-
"display_name": data.get("displayName"
|
|
43
|
+
"display_name": data.get("displayName"),
|
|
42
44
|
"created_at": created_at,
|
|
43
45
|
"timestamp_utc": timestamp_utc,
|
|
44
|
-
"description": data
|
|
45
|
-
"avatar": data.get("avatar"
|
|
46
|
+
"description": data.get("description"),
|
|
47
|
+
"avatar": data.get("avatar"),
|
|
46
48
|
"posts": data["postsCount"],
|
|
47
49
|
"followers": data["followersCount"],
|
|
48
50
|
"follows": data["followsCount"],
|
|
49
51
|
"lists": associated["lists"],
|
|
50
52
|
"feedgens": associated["feedgens"],
|
|
51
53
|
"starter_packs": associated["starterPacks"],
|
|
52
|
-
"banner": data
|
|
54
|
+
"banner": data.get("banner"),
|
|
53
55
|
"pinned_post_uri": pinned_post_uri,
|
|
54
56
|
"collection_time": get_collection_time(),
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
|
|
60
|
+
def normalize_partial_profile(
|
|
61
|
+
data: Dict, locale: Optional[Any] = None
|
|
62
|
+
) -> BlueskyPartialProfile:
|
|
63
|
+
associated = data["associated"]
|
|
64
|
+
|
|
65
|
+
timestamp_utc, created_at = get_dates(
|
|
66
|
+
data["createdAt"], locale=locale, source="bluesky"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
"did": data["did"],
|
|
71
|
+
"url": format_profile_url(data["handle"]),
|
|
72
|
+
"handle": data["handle"],
|
|
73
|
+
"display_name": data.get("displayName"),
|
|
74
|
+
"created_at": created_at,
|
|
75
|
+
"timestamp_utc": timestamp_utc,
|
|
76
|
+
"description": data.get("description"),
|
|
77
|
+
"avatar": data.get("avatar"),
|
|
78
|
+
"lists": associated.get("lists"),
|
|
79
|
+
"feedgens": associated.get("feedgens"),
|
|
80
|
+
"starter_packs": associated.get("starterPacks"),
|
|
81
|
+
"collection_time": get_collection_time(),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
58
85
|
def prepare_native_gif_as_media(gif_data, user_did, source):
|
|
59
86
|
if "thumb" in gif_data:
|
|
60
87
|
media_cid = gif_data["thumb"]["ref"]["$link"]
|
|
@@ -125,7 +152,14 @@ def prepare_quote_data(embed_quote, card_data, post, links):
|
|
|
125
152
|
)
|
|
126
153
|
|
|
127
154
|
# First store ugly quoted url with user did in case full quote data is missing (recursion > 3 or detached quote)
|
|
128
|
-
|
|
155
|
+
# Handling special posts types (only lists for now, for example: https://bsky.app/profile/lanana421.bsky.social/lists/3lxdgjtpqhf2z)
|
|
156
|
+
if "/app.bsky.graph.list/" in post["quoted_uri"]:
|
|
157
|
+
post_splitter = "/lists/"
|
|
158
|
+
else:
|
|
159
|
+
post_splitter = "/post/"
|
|
160
|
+
post["quoted_url"] = format_post_url(
|
|
161
|
+
post["quoted_user_did"], post["quoted_did"], post_splitter=post_splitter
|
|
162
|
+
)
|
|
129
163
|
|
|
130
164
|
quoted_data = None
|
|
131
165
|
if card_data:
|
|
@@ -202,7 +236,7 @@ def normalize_post(
|
|
|
202
236
|
|
|
203
237
|
def normalize_post(
|
|
204
238
|
payload: Dict,
|
|
205
|
-
locale: Optional[
|
|
239
|
+
locale: Optional[Any] = None,
|
|
206
240
|
extract_referenced_posts: bool = False,
|
|
207
241
|
collection_source: Optional[str] = None,
|
|
208
242
|
) -> Union[BlueskyPost, List[BlueskyPost]]:
|
|
@@ -347,16 +381,28 @@ def normalize_post(
|
|
|
347
381
|
# Handle native polls
|
|
348
382
|
if "https://poll.blue/" in feat["uri"]:
|
|
349
383
|
if feat["uri"].endswith("/0"):
|
|
350
|
-
|
|
384
|
+
link = safe_normalize_url(feat["uri"])
|
|
385
|
+
if is_url(link):
|
|
386
|
+
links.add(link)
|
|
387
|
+
else:
|
|
388
|
+
continue
|
|
351
389
|
text += b" %s" % feat["uri"].encode("utf-8")
|
|
352
390
|
continue
|
|
353
391
|
|
|
354
|
-
|
|
392
|
+
link = safe_normalize_url(feat["uri"])
|
|
393
|
+
if is_url(link):
|
|
394
|
+
links.add(link)
|
|
395
|
+
else:
|
|
396
|
+
continue
|
|
355
397
|
# Check & fix occasional errored link positioning
|
|
356
|
-
#
|
|
398
|
+
# examples: https://bsky.app/profile/ecrime.ch/post/3lqotmopayr23
|
|
399
|
+
# https://bsky.app/profile/clustz.com/post/3lqfi7mnto52w
|
|
357
400
|
byteStart = facet["index"]["byteStart"]
|
|
358
|
-
|
|
359
|
-
|
|
401
|
+
|
|
402
|
+
if not text[byteStart : facet["index"]["byteEnd"]].startswith(b"http"):
|
|
403
|
+
new_byteStart = text.find(b"http", byteStart, facet["index"]["byteEnd"])
|
|
404
|
+
if new_byteStart != -1:
|
|
405
|
+
byteStart = new_byteStart
|
|
360
406
|
|
|
361
407
|
links_to_replace.append(
|
|
362
408
|
{
|
|
@@ -440,7 +486,7 @@ def normalize_post(
|
|
|
440
486
|
|
|
441
487
|
# Extra card links sometimes missing from facets & text due to manual action in post form
|
|
442
488
|
else:
|
|
443
|
-
extra_links.append(
|
|
489
|
+
extra_links.append(link)
|
|
444
490
|
# Handle link card metadata
|
|
445
491
|
if "embed" in data:
|
|
446
492
|
post = process_card_data(data["embed"]["external"], post)
|
|
@@ -515,9 +561,10 @@ def normalize_post(
|
|
|
515
561
|
|
|
516
562
|
# Process extra links
|
|
517
563
|
for link in extra_links:
|
|
518
|
-
norm_link =
|
|
564
|
+
norm_link = safe_normalize_url(link)
|
|
519
565
|
if norm_link not in links:
|
|
520
|
-
|
|
566
|
+
if is_url(norm_link):
|
|
567
|
+
links.add(norm_link)
|
|
521
568
|
text += b" " + link.encode("utf-8")
|
|
522
569
|
|
|
523
570
|
# Process medias
|
|
@@ -547,11 +594,13 @@ def normalize_post(
|
|
|
547
594
|
|
|
548
595
|
# Process quotes
|
|
549
596
|
if quoted_data and "value" in quoted_data:
|
|
550
|
-
|
|
597
|
+
# We're checking on the uri as the cid can be different in some cases,
|
|
598
|
+
# and the uri seems to be unique for each post
|
|
599
|
+
if quoted_data["uri"] != post["quoted_uri"]:
|
|
551
600
|
raise BlueskyPayloadError(
|
|
552
601
|
post["url"],
|
|
553
|
-
"inconsistent quote
|
|
554
|
-
% (post["
|
|
602
|
+
"inconsistent quote uri found between record.embed.record.uri & embed.record.uri: %s %s"
|
|
603
|
+
% (post["quoted_uri"], quoted_data),
|
|
555
604
|
)
|
|
556
605
|
|
|
557
606
|
quoted_data["record"] = quoted_data["value"]
|
|
@@ -659,7 +708,12 @@ def normalize_post(
|
|
|
659
708
|
repost_data["indexedAt"], locale=locale, source="bluesky"
|
|
660
709
|
)
|
|
661
710
|
|
|
662
|
-
|
|
711
|
+
try:
|
|
712
|
+
post["text"] = text.decode("utf-8")
|
|
713
|
+
except UnicodeDecodeError as e:
|
|
714
|
+
raise UnicodeDecodeError(
|
|
715
|
+
f"Failed to decode post text: {e}\nPost URL: {post['url']}\nOriginal text bytes: {text}"
|
|
716
|
+
)
|
|
663
717
|
|
|
664
718
|
if collection_source is not None:
|
|
665
719
|
post["collected_via"] = [collection_source]
|
twitwi/bluesky/types.py
CHANGED
|
@@ -10,7 +10,7 @@ class BlueskyProfile(TypedDict):
|
|
|
10
10
|
url: str # URL of the profile accessible on the web
|
|
11
11
|
handle: str # updatable human-readable username of the account (usually like username.bsky.social or username.com)
|
|
12
12
|
display_name: Optional[str] # updatable human-readable name of the account
|
|
13
|
-
description: str
|
|
13
|
+
description: Optional[str] # profile short description written by the user
|
|
14
14
|
posts: int # total number of posts submitted by the user (at collection time)
|
|
15
15
|
followers: int # total number of followers of the user (at collection time)
|
|
16
16
|
follows: int # total number of other users followed by the user (at collection time)
|
|
@@ -18,12 +18,26 @@ class BlueskyProfile(TypedDict):
|
|
|
18
18
|
feedgens: int # total number of custom feeds created by the user (at collection time)
|
|
19
19
|
starter_packs: int # total number of starter packs created by the user (at collection time)
|
|
20
20
|
avatar: Optional[str] # URL to the image serving as avatar to the user
|
|
21
|
-
banner: str
|
|
21
|
+
banner: Optional[str] # URL to the image serving as profile banner to the user
|
|
22
22
|
pinned_post_uri: Optional[str] # ATProto's internal URI to the post potentially pinned by the user to appear at the top of his posts on his profile
|
|
23
23
|
created_at: str # datetime (potentially timezoned) of when the user created the account
|
|
24
24
|
timestamp_utc: int # Unix UTC timestamp of when the user created the account
|
|
25
25
|
collection_time: Optional[str] # datetime (potentially timezoned) of when the data was normalized
|
|
26
26
|
|
|
27
|
+
class BlueskyPartialProfile(TypedDict): # A partial version of the profile found in follower/follow profile payloads
|
|
28
|
+
did: str # persistent long-term identifier of the account
|
|
29
|
+
url: str # URL of the profile accessible on the web
|
|
30
|
+
handle: str # updatable human-readable username of the account (usually like username.bsky.social or username.com)
|
|
31
|
+
display_name: Optional[str] # updatable human-readable name of the account
|
|
32
|
+
description: Optional[str] # profile short description written by the user
|
|
33
|
+
lists: Optional[int] # total number of lists created by the user (at collection time)
|
|
34
|
+
feedgens: Optional[int] # total number of custom feeds created by the user (at collection time)
|
|
35
|
+
starter_packs: Optional[int] # total number of starter packs created by the user (at collection time)
|
|
36
|
+
avatar: Optional[str] # URL to the image serving as avatar to the user
|
|
37
|
+
created_at: str # datetime (potentially timezoned) of when the user created the account
|
|
38
|
+
timestamp_utc: int # Unix UTC timestamp of when the user created the account
|
|
39
|
+
collection_time: Optional[str] # datetime (potentially timezoned) of when the data was normalized
|
|
40
|
+
|
|
27
41
|
|
|
28
42
|
|
|
29
43
|
class BlueskyPost(TypedDict):
|
twitwi/bluesky/utils.py
CHANGED
|
@@ -55,7 +55,9 @@ def validate_post_payload(data):
|
|
|
55
55
|
return True, None
|
|
56
56
|
|
|
57
57
|
|
|
58
|
-
re_embed_types = re.compile(
|
|
58
|
+
re_embed_types = re.compile(
|
|
59
|
+
r"\.(record|recordWithMedia|images|video|external)(?:#.*)?$"
|
|
60
|
+
)
|
|
59
61
|
|
|
60
62
|
|
|
61
63
|
def valid_embed_type(embed_type):
|
|
@@ -66,29 +68,39 @@ def format_profile_url(user_handle_or_did):
|
|
|
66
68
|
return f"https://bsky.app/profile/{user_handle_or_did}"
|
|
67
69
|
|
|
68
70
|
|
|
69
|
-
def format_post_url(user_handle_or_did, post_did):
|
|
70
|
-
return f"https://bsky.app/profile/{user_handle_or_did}
|
|
71
|
+
def format_post_url(user_handle_or_did, post_did, post_splitter="/post/"):
|
|
72
|
+
return f"https://bsky.app/profile/{user_handle_or_did}{post_splitter}{post_did}"
|
|
71
73
|
|
|
72
74
|
|
|
73
75
|
def parse_post_url(url, source):
|
|
74
76
|
"""Returns a tuple of (author_handle/did, post_did) from an https://bsky.app post URL"""
|
|
75
77
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
known_splits = ["/post/", "/lists/"]
|
|
79
|
+
|
|
80
|
+
if url.startswith("https://bsky.app/profile/"):
|
|
81
|
+
for split in known_splits:
|
|
82
|
+
if split in url[25:]:
|
|
83
|
+
return url[25:].split(split)
|
|
84
|
+
|
|
85
|
+
raise BlueskyPayloadError(source, f"{url} is not a usual Bluesky post url")
|
|
79
86
|
|
|
80
87
|
|
|
81
88
|
def parse_post_uri(uri, source=None):
|
|
82
89
|
"""Returns a tuple of (author_did, post_did) from an at:// post URI"""
|
|
83
90
|
|
|
84
|
-
|
|
85
|
-
|
|
91
|
+
known_splits = [
|
|
92
|
+
"/app.bsky.feed.post/",
|
|
93
|
+
"/app.bsky.graph.starterpack/",
|
|
94
|
+
"/app.bsky.feed.generator/",
|
|
95
|
+
"/app.bsky.graph.list/",
|
|
96
|
+
]
|
|
86
97
|
|
|
87
|
-
if
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
98
|
+
if uri.startswith("at://"):
|
|
99
|
+
for split in known_splits:
|
|
100
|
+
if split in uri:
|
|
101
|
+
return uri[5:].split(split)
|
|
102
|
+
|
|
103
|
+
raise BlueskyPayloadError(source or uri, f"{uri} is not a usual Bluesky post uri")
|
|
92
104
|
|
|
93
105
|
|
|
94
106
|
def format_starterpack_url(user_handle_or_did, record_did):
|
|
@@ -105,6 +117,13 @@ def format_media_url(user_did, media_cid, mime_type, source):
|
|
|
105
117
|
media_thumb = (
|
|
106
118
|
f"https://video.bsky.app/watch/{user_did}/{media_cid}/thumbnail.jpg"
|
|
107
119
|
)
|
|
120
|
+
elif mime_type == "application/octet-stream":
|
|
121
|
+
media_url = (
|
|
122
|
+
f"https://cdn.bsky.app/img/feed_fullsize/plain/{user_did}/{media_cid}@jpeg"
|
|
123
|
+
)
|
|
124
|
+
media_thumb = (
|
|
125
|
+
f"https://cdn.bsky.app/img/feed_thumbnail/plain/{user_did}/{media_cid}@jpeg"
|
|
126
|
+
)
|
|
108
127
|
else:
|
|
109
|
-
raise BlueskyPayloadError(source, f"{mime_type} is an
|
|
128
|
+
raise BlueskyPayloadError(source, f"{mime_type} is an unusual media mimeType")
|
|
110
129
|
return media_url, media_thumb
|
twitwi/utils.py
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
#
|
|
5
5
|
# Miscellaneous utility functions.
|
|
6
6
|
#
|
|
7
|
+
from typing import Tuple
|
|
8
|
+
|
|
7
9
|
from pytz import timezone
|
|
8
10
|
from dateutil.parser import parse as parse_date
|
|
9
11
|
from ural import normalize_url, get_normalized_hostname
|
|
@@ -25,6 +27,21 @@ UTC_TIMEZONE = timezone("UTC")
|
|
|
25
27
|
|
|
26
28
|
custom_normalize_url = partial(normalize_url, **CANONICAL_URL_KWARGS)
|
|
27
29
|
|
|
30
|
+
|
|
31
|
+
def safe_normalize_url(url):
|
|
32
|
+
# We avoid normalizing bluesky urls containing specific uri parts because
|
|
33
|
+
# Bluesky servers don't handle quoting correctly...
|
|
34
|
+
# See https://github.com/medialab/twitwi/issues/72
|
|
35
|
+
if "/did:plc:" in url:
|
|
36
|
+
return url
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
return custom_normalize_url(url)
|
|
40
|
+
except Exception:
|
|
41
|
+
# In case of error, return the original URL. Possibly not a valid URL, e.g. url containing double slashes
|
|
42
|
+
return url
|
|
43
|
+
|
|
44
|
+
|
|
28
45
|
custom_get_normalized_hostname = partial(
|
|
29
46
|
get_normalized_hostname, **CANONICAL_HOSTNAME_KWARGS
|
|
30
47
|
)
|
|
@@ -34,7 +51,9 @@ def get_collection_time():
|
|
|
34
51
|
return datetime.now().strftime(FORMATTED_FULL_DATETIME_FORMAT)
|
|
35
52
|
|
|
36
53
|
|
|
37
|
-
def get_dates(
|
|
54
|
+
def get_dates(
|
|
55
|
+
date_str: str, locale=None, source: str = "v1", millisecond_timestamp: bool = False
|
|
56
|
+
) -> Tuple[int, str]:
|
|
38
57
|
if source not in ["v1", "v2", "bluesky"]:
|
|
39
58
|
raise Exception("source should be one of v1, v2 or bluesky")
|
|
40
59
|
|
|
@@ -56,8 +75,14 @@ def get_dates(date_str, locale=None, source="v1"):
|
|
|
56
75
|
utc_datetime = UTC_TIMEZONE.localize(parsed_datetime)
|
|
57
76
|
locale_datetime = utc_datetime.astimezone(locale)
|
|
58
77
|
|
|
78
|
+
timestamp = int(utc_datetime.timestamp())
|
|
79
|
+
|
|
80
|
+
if millisecond_timestamp:
|
|
81
|
+
timestamp *= 1000
|
|
82
|
+
timestamp += utc_datetime.microsecond / 1000
|
|
83
|
+
|
|
59
84
|
return (
|
|
60
|
-
int(
|
|
85
|
+
int(timestamp),
|
|
61
86
|
datetime.strftime(
|
|
62
87
|
locale_datetime,
|
|
63
88
|
FORMATTED_FULL_DATETIME_FORMAT
|
|
@@ -106,6 +131,7 @@ def get_dates_from_id(tweet_id, locale=None):
|
|
|
106
131
|
locale = UTC_TIMEZONE
|
|
107
132
|
|
|
108
133
|
timestamp = get_timestamp_from_id(tweet_id)
|
|
134
|
+
assert timestamp is not None
|
|
109
135
|
|
|
110
136
|
locale_datetime = datetime.fromtimestamp(timestamp, locale)
|
|
111
137
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: twitwi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.22.1
|
|
4
4
|
Summary: A collection of Twitter-related helper functions for python.
|
|
5
5
|
Home-page: http://github.com/medialab/twitwi
|
|
6
6
|
Author: Béatrice Mazoyer, Guillaume Plique, Benjamin Ooghe-Tabanou
|
|
@@ -56,18 +56,22 @@ pip install twitwi
|
|
|
56
56
|
*Normalization functions*
|
|
57
57
|
|
|
58
58
|
* [normalize_profile](#normalize_profile)
|
|
59
|
+
* [normalize_partial_profile](#normalize_partial_profile)
|
|
59
60
|
* [normalize_post](#normalize_post)
|
|
60
61
|
|
|
61
62
|
*Formatting functions*
|
|
62
63
|
|
|
63
64
|
* [transform_profile_into_csv_dict](#transform_profile_into_csv_dict)
|
|
65
|
+
* [transform_partial_profile_into_csv_dict](#transform_partial_profile_into_csv_dict)
|
|
64
66
|
* [transform_post_into_csv_dict](#transform_post_into_csv_dict)
|
|
65
67
|
* [format_profile_as_csv_row](#format_profile_as_csv_row)
|
|
68
|
+
* [format_partial_profile_as_csv_row](#format_partial_profile_as_csv_row)
|
|
66
69
|
* [format_post_as_csv_row](#format_post_as_csv_row)
|
|
67
70
|
|
|
68
71
|
*Useful constants (under `twitwi.bluesky.constants`)*
|
|
69
72
|
|
|
70
73
|
* [PROFILE_FIELDS](#profile_fields)
|
|
74
|
+
* [PARTIAL_PROFILE_FIELDS](#partial_profile_fields)
|
|
71
75
|
* [POST_FIELDS](#post_fields)
|
|
72
76
|
|
|
73
77
|
*Examples*
|
|
@@ -179,7 +183,18 @@ with open("normalized_bluesky_profiles.csv", "w") as f:
|
|
|
179
183
|
|
|
180
184
|
### normalize_profile
|
|
181
185
|
|
|
182
|
-
Function taking a nested dict describing a user profile from Bluesky's JSON payload and returning a flat "normalized" dict composed of all [PROFILE_FIELDS](#profile_fields) keys.
|
|
186
|
+
Function taking a nested dict describing a user profile from Bluesky's JSON payload (with the same format as retrieved from [`app.bsky.actor.getProfiles` HTTP endpoint](docs.bsky.app/docs/api/app-bsky-actor-get-profiles#responses)) and returning a flat "normalized" dict composed of all [PROFILE_FIELDS](#profile_fields) keys. Be careful not to confuse with the [normalize_partial_profile](#normalize_partial_profile) function which operate on a lighter version of the profile data, retrieved from [follower/follow profile payloads](https://docs.bsky.app/docs/api/app-bsky-graph-get-followers#responses) for example.
|
|
187
|
+
|
|
188
|
+
Will return datetimes as UTC but can take an optional second `locale` argument as a [`pytz`](https://pypi.org/project/pytz/) string timezone.
|
|
189
|
+
|
|
190
|
+
*Arguments*
|
|
191
|
+
|
|
192
|
+
* **data** *(dict)*: user profile data payload coming from Bluesky API.
|
|
193
|
+
* **locale** *(pytz.timezone as str, optional)*: timezone used to convert dates. If not given, will default to UTC.
|
|
194
|
+
|
|
195
|
+
### normalize_partial_profile
|
|
196
|
+
|
|
197
|
+
Function taking a nested dict describing a user profile from Bluesky's JSON payload (with the same format as retrieved from [`app.bsky.graph.getFollowers` HTTP endpoint](https://docs.bsky.app/docs/api/app-bsky-graph-get-followers#responses)) and returning a flat "normalized" dict composed of all [PARTIAL_PROFILE_FIELDS](#partial_profile_fields) keys. Be careful not to confuse with the [normalize_profile](#normalize_profile) function which operate on the full version of the profile data, retrieved from [`app.bsky.actor.getProfiles` HTTP endpoint](docs.bsky.app/docs/api/app-bsky-actor-get-profiles#responses) for example.
|
|
183
198
|
|
|
184
199
|
Will return datetimes as UTC but can take an optional second `locale` argument as a [`pytz`](https://pypi.org/project/pytz/) string timezone.
|
|
185
200
|
|
|
@@ -209,6 +224,12 @@ Function transforming (i.e. mutating, so beware) a given normalized Bluesky prof
|
|
|
209
224
|
|
|
210
225
|
Will convert list elements of the normalized data into a string with all elements separated by the `|` character, which can be changed using an optional `plural_separator` argument.
|
|
211
226
|
|
|
227
|
+
### transform_partial_profile_into_csv_dict
|
|
228
|
+
|
|
229
|
+
Function transforming (i.e. mutating, so beware) a given normalized Bluesky partial profile into a suitable dict able to be written by a `csv.DictWriter` as a row.
|
|
230
|
+
|
|
231
|
+
Will convert list elements of the normalized data into a string with all elements separated by the `|` character, which can be changed using an optional `plural_separator` argument.
|
|
232
|
+
|
|
212
233
|
### transform_post_into_csv_dict
|
|
213
234
|
|
|
214
235
|
Function transforming (i.e. mutating, so beware) a given normalized Bluesky post into a suitable dict able to be written by a `csv.DictWriter` as a row.
|
|
@@ -221,6 +242,12 @@ Function formatting the given normalized Bluesky profile as a list able to be wr
|
|
|
221
242
|
|
|
222
243
|
Will convert list elements of the normalized data into a string with all elements separated by the `|` character, which can be changed using an optional `plural_separator` argument.
|
|
223
244
|
|
|
245
|
+
### format_partial_profile_as_csv_row
|
|
246
|
+
|
|
247
|
+
Function formatting the given normalized Bluesky partial profile as a list able to be written by a `csv.writer` as a row in the order of [PARTIAL_PROFILE_FIELDS](#partial_profile_fields) (which can therefore be used as header row of the CSV).
|
|
248
|
+
|
|
249
|
+
Will convert list elements of the normalized data into a string with all elements separated by the `|` character, which can be changed using an optional `plural_separator` argument.
|
|
250
|
+
|
|
224
251
|
### format_post_as_csv_row
|
|
225
252
|
|
|
226
253
|
Function formatting the given normalized tBluesky post as a list able to be written by a `csv.writer` as a row in the order of [POST_FIELDS](#post_fields) (which can therefore be used as header row of the CSV).
|
|
@@ -229,7 +256,11 @@ Will convert list elements of the normalized data into a string with all element
|
|
|
229
256
|
|
|
230
257
|
### PROFILE_FIELDS
|
|
231
258
|
|
|
232
|
-
List of a Bluesky user profile's normalized field names. Useful to declare headers with csv writers.
|
|
259
|
+
List of a Bluesky user profile's normalized field names. Useful to declare headers with csv writers. Be careful not to confuse with [PARTIAL_PROFILE_FIELDS](#partial_profile_fields) which correspond to a lighter version of the profile data, retrieved from [follower/follow profile payloads](https://docs.bsky.app/docs/api/app-bsky-graph-get-followers#responses) for example.
|
|
260
|
+
|
|
261
|
+
### PARTIAL_PROFILE_FIELDS
|
|
262
|
+
|
|
263
|
+
List of a Bluesky user partial profile's (retrieved from [`app.bsky.graph.getFollowers` HTTP endpoint](https://docs.bsky.app/docs/api/app-bsky-graph-get-followers#responses) for example) normalized field names. Useful to declare headers with csv writers. Be careful not to confuse with [PROFILE_FIELDS](#profile_fields) which correspond to the full version of the profile data, retrieved from [`app.bsky.actor.getProfiles` HTTP endpoint](docs.bsky.app/docs/api/app-bsky-actor-get-profiles#responses) for example.
|
|
233
264
|
|
|
234
265
|
### POST_FIELDS
|
|
235
266
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
test/bluesky/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
test/bluesky/formatters_test.py,sha256=dMpRV_IuStZAnXhJGKnYsi0tb4BaSTSU4JVfihU1aQs,5002
|
|
3
|
+
test/bluesky/normalizers_test.py,sha256=R4NziqErGW5MBdQEZ1vNxLGNRvJTyGnXfqo0v5gBCgw,5662
|
|
4
|
+
twitwi/__init__.py,sha256=y0bAx9gE3THtlWE1YpXDIhGwqJ5_I8DCStWyyiiXJkw,1095
|
|
5
|
+
twitwi/anonymizers.py,sha256=nkl6HL1BWLz00wJ060XSbqjN5JF8pvcpEPnRXt70TUY,1588
|
|
6
|
+
twitwi/constants.py,sha256=fvqCngJIGyz5CpdVWbcAfjmE3_kvcx9giN0rEljL7OU,16001
|
|
7
|
+
twitwi/exceptions.py,sha256=OCIDagu2ErDyOGWunRBCK3O62TnzFpIMQ9gS8l9EALQ,696
|
|
8
|
+
twitwi/formatters.py,sha256=yn14AsrGAUw8rShOnYJvoMbzdWpfTeSs0P0ZPNTwhLU,3142
|
|
9
|
+
twitwi/normalizers.py,sha256=CWUK-XwhcEjLDjWH_qb6E03WZKsbIcwiRAVUjwXKQho,28438
|
|
10
|
+
twitwi/utils.py,sha256=f02cMx19Sr_GvJQf_0jTIERGLq1oC3znnPQxE__rlFc,3838
|
|
11
|
+
twitwi/bluesky/__init__.py,sha256=SqeHZUzL2U9UpL3EB33vaowQWaKXSPkvsAkasRqmFpY,694
|
|
12
|
+
twitwi/bluesky/constants.py,sha256=CPkTIrDwyRWpkFTbaee1oFm_LWGj2WIC7A6xEGqDGB4,573
|
|
13
|
+
twitwi/bluesky/formatters.py,sha256=L_yROAPcBECifCGiFAGYFJwLq6re8UlJNoZ7R2DXm5g,1025
|
|
14
|
+
twitwi/bluesky/normalizers.py,sha256=MtS1UW9Chi8zzs8VBfyXxDQaeIrEEXeNy3GBvgG_ivc,28759
|
|
15
|
+
twitwi/bluesky/types.py,sha256=WUxfyA5fc68qURGh7bxiDlIBFgdbyysRRdvHLoXwWlA,13656
|
|
16
|
+
twitwi/bluesky/utils.py,sha256=9il8t_qkKCmGQ-MDkF5qahxKV1Qsmwzul_1VzzD-jH4,3943
|
|
17
|
+
twitwi-0.22.1.dist-info/licenses/LICENSE.txt,sha256=Ddg_PcGnl0qd2167o2dheCjE_rCZJOoBxjJnJhhOpX4,1099
|
|
18
|
+
twitwi-0.22.1.dist-info/METADATA,sha256=1B2fgQqdo1CpPDe0g-NWJzqQ5XqZZEkRl1osx1bxht8,21365
|
|
19
|
+
twitwi-0.22.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
20
|
+
twitwi-0.22.1.dist-info/top_level.txt,sha256=TaKyGU7j_EVbP5KI0UD6qjbaKv2Qn0OrkfUQ29a04kg,12
|
|
21
|
+
twitwi-0.22.1.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
22
|
+
twitwi-0.22.1.dist-info/RECORD,,
|
twitwi-0.21.2.dist-info/RECORD
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
test/bluesky/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
test/bluesky/formatters_test.py,sha256=PUEFGdp6fUpNhiWaoEC1-CXfX9L3jPDYcAjoOZ_DiXQ,3296
|
|
3
|
-
test/bluesky/normalizers_test.py,sha256=3c6xF3Bwdl-kmqhb-8O4NEam5IdcbSPA44I-vsN_3po,4417
|
|
4
|
-
twitwi/__init__.py,sha256=y0bAx9gE3THtlWE1YpXDIhGwqJ5_I8DCStWyyiiXJkw,1095
|
|
5
|
-
twitwi/anonymizers.py,sha256=nkl6HL1BWLz00wJ060XSbqjN5JF8pvcpEPnRXt70TUY,1588
|
|
6
|
-
twitwi/constants.py,sha256=fvqCngJIGyz5CpdVWbcAfjmE3_kvcx9giN0rEljL7OU,16001
|
|
7
|
-
twitwi/exceptions.py,sha256=OCIDagu2ErDyOGWunRBCK3O62TnzFpIMQ9gS8l9EALQ,696
|
|
8
|
-
twitwi/formatters.py,sha256=yn14AsrGAUw8rShOnYJvoMbzdWpfTeSs0P0ZPNTwhLU,3142
|
|
9
|
-
twitwi/normalizers.py,sha256=CWUK-XwhcEjLDjWH_qb6E03WZKsbIcwiRAVUjwXKQho,28438
|
|
10
|
-
twitwi/utils.py,sha256=8BiUrkzeydTh4a-rcFW0IHhSNdxlvCXaEpHZGVROl3A,3090
|
|
11
|
-
twitwi/bluesky/__init__.py,sha256=7ITVm1bWE9p0HCWcUs2iOxwKQXK8NEzrvp9hXaicrp0,445
|
|
12
|
-
twitwi/bluesky/constants.py,sha256=CSxNZGYlZvlZ0IMYpP8tVTO6AnzoTmGDFh185hs8n88,473
|
|
13
|
-
twitwi/bluesky/formatters.py,sha256=r8MKOpU8z024z9DQ8-tlKjzaB92T1Su2IjcFV5hjfXg,731
|
|
14
|
-
twitwi/bluesky/normalizers.py,sha256=prINPplJ01pYWWMbDlMbkaTi-31N3uJGrHnTRhGDb2Q,26808
|
|
15
|
-
twitwi/bluesky/types.py,sha256=63eVbCc1ZpyBnh2A3D5_pyteETE395rcSdFcu6qppjI,12312
|
|
16
|
-
twitwi/bluesky/utils.py,sha256=KQd0YVWZHcCRcqnX5A1aeKdavwocN6Bsi8pCnHqRgXc,3486
|
|
17
|
-
twitwi-0.21.2.dist-info/licenses/LICENSE.txt,sha256=Ddg_PcGnl0qd2167o2dheCjE_rCZJOoBxjJnJhhOpX4,1099
|
|
18
|
-
twitwi-0.21.2.dist-info/METADATA,sha256=lJvhL_S-LydAaWPtkvapFvmLEPzUVEVsJ3t7YxsW3w0,17978
|
|
19
|
-
twitwi-0.21.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
20
|
-
twitwi-0.21.2.dist-info/top_level.txt,sha256=TaKyGU7j_EVbP5KI0UD6qjbaKv2Qn0OrkfUQ29a04kg,12
|
|
21
|
-
twitwi-0.21.2.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
22
|
-
twitwi-0.21.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|