Merge pull request #100 from jaywink/share-entity

Added base entity Share
merge-requests/130/head
Jason Robinson 2017-08-22 12:21:54 +03:00 zatwierdzone przez GitHub
commit bcc779e006
11 zmienionych plików z 255 dodań i 86 usunięć

Wyświetl plik

@ -2,6 +2,11 @@
## [unreleased]
### Added
* Added base entity `Share` which maps to a `DiasporaReshare` for the Diaspora protocol. ([related issue](https://github.com/jaywink/federation/issues/94))
The `Share` entity supports all the properties that a Diaspora reshare does. Additionally two other properties are supported: `raw_content` and `entity_type`. The former can be used for a "quoted share" case where the sharer adds their own note to the share. The latter can be used to reference the type of object that was shared, to help the receiver, if it is not sharing a `Post` entity. The value must be a base entity class name.
### Fixed
* Converting base entity `Profile` to `DiasporaProfile` for outbound sending missed two attributes, `image_urls` and `tag_list`. Those are now included so that the values transfer into the built payload.

Wyświetl plik

@ -24,6 +24,7 @@ The feature set supported by this release is approximately the following:
* Retraction
* StatusMessage
* Contact
* Reshare
Implementation unfortunately currently requires knowledge of how Diaspora discovery works as the implementer has to implement all the necessary views correctly (even though this library provides document generators). However, the magic envelope, signature and entity building is all abstracted inside the library.

Wyświetl plik

@ -17,6 +17,7 @@ Entity types are as follows below.
.. autoclass:: federation.entities.base.Reaction
.. autoclass:: federation.entities.base.Relationship
.. autoclass:: federation.entities.base.Retraction
.. autoclass:: federation.entities.base.Share
Protocol entities
.................

Wyświetl plik

@ -5,7 +5,7 @@ from dirty_validators.basic import Email
__all__ = (
"Post", "Image", "Comment", "Reaction", "Relationship", "Profile", "Retraction", "Follow",
"Post", "Image", "Comment", "Reaction", "Relationship", "Profile", "Retraction", "Follow", "Share,"
)
@ -125,6 +125,24 @@ class TargetGUIDMixin(BaseEntity):
raise ValueError("Target GUID must be at least 16 characters")
class ParticipationMixin(TargetGUIDMixin):
"""Reflects a participation to something."""
participation = ""
_participation_valid_values = ["reaction", "subscription", "comment"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._required += ["participation"]
def validate_participation(self):
"""Ensure participation is of a certain type."""
if self.participation not in self._participation_valid_values:
raise ValueError("participation should be one of: {valid}".format(
valid=", ".join(self._participation_valid_values)
))
class HandleMixin(BaseEntity):
handle = ""
@ -178,7 +196,43 @@ class OptionalRawContentMixin(RawContentMixin):
self._required.remove("raw_content")
class Image(GUIDMixin, HandleMixin, PublicMixin, OptionalRawContentMixin, CreatedAtMixin, BaseEntity):
class EntityTypeMixin(BaseEntity):
"""Provides a field for entity type.
Validates it is one of our entities.
"""
entity_type = ""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._required += ["entity_type"]
def validate_entity_type(self):
"""Ensure type is some entity we know of."""
if self.entity_type not in __all__:
raise ValueError("Entity type %s not recognized." % self.entity_type)
class ProviderDisplayNameMixin(BaseEntity):
"""Provides a field for provider display name."""
provider_display_name = ""
class TargetHandleMixin(BaseEntity):
"""Provides a target handle field."""
target_handle = ""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._required += ["target_handle"]
def validate_target_handle(self):
validator = Email()
if not validator.is_valid(self.target_handle):
raise ValueError("Target handle is not valid")
class Image(GUIDMixin, HandleMixin, PublicMixin, OptionalRawContentMixin, CreatedAtMixin):
"""Reflects a single image, possibly linked to another object."""
remote_path = ""
remote_name = ""
@ -192,32 +246,13 @@ class Image(GUIDMixin, HandleMixin, PublicMixin, OptionalRawContentMixin, Create
self._required += ["remote_path", "remote_name"]
class Post(RawContentMixin, GUIDMixin, HandleMixin, PublicMixin, CreatedAtMixin, BaseEntity):
class Post(RawContentMixin, GUIDMixin, HandleMixin, PublicMixin, CreatedAtMixin, ProviderDisplayNameMixin):
"""Reflects a post, status message, etc, which will be composed from the message or to the message."""
provider_display_name = ""
location = ""
_allowed_children = (Image,)
class ParticipationMixin(TargetGUIDMixin):
"""Reflects a participation to something."""
participation = ""
_participation_valid_values = ["reaction", "subscription", "comment"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._required += ["participation"]
def validate_participation(self):
"""Ensure participation is of a certain type."""
if self.participation not in self._participation_valid_values:
raise ValueError("participation should be one of: {valid}".format(
valid=", ".join(self._participation_valid_values)
))
class Comment(RawContentMixin, GUIDMixin, ParticipationMixin, CreatedAtMixin, HandleMixin):
"""Represents a comment, linked to another object."""
participation = "comment"
@ -247,21 +282,15 @@ class Reaction(GUIDMixin, ParticipationMixin, CreatedAtMixin, HandleMixin):
))
class Relationship(CreatedAtMixin, HandleMixin):
class Relationship(CreatedAtMixin, HandleMixin, TargetHandleMixin):
"""Represents a relationship between two handles."""
target_handle = ""
relationship = ""
_relationship_valid_values = ["sharing", "following", "ignoring", "blocking"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._required += ["relationship", "target_handle"]
def validate_target_handle(self):
validator = Email()
if not validator.is_valid(self.target_handle):
raise ValueError("Target handle is not valid")
self._required += ["relationship"]
def validate_relationship(self):
"""Ensure relationship is of a certain type."""
@ -271,19 +300,13 @@ class Relationship(CreatedAtMixin, HandleMixin):
))
class Follow(CreatedAtMixin, HandleMixin):
class Follow(CreatedAtMixin, HandleMixin, TargetHandleMixin):
"""Represents a handle following or unfollowing another handle."""
target_handle = ""
following = True
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._required += ["target_handle", "following"]
def validate_target_handle(self):
validator = Email()
if not validator.is_valid(self.target_handle):
raise ValueError("Target handle is not valid")
self._required += ["following"]
class Profile(CreatedAtMixin, HandleMixin, OptionalRawContentMixin, PublicMixin, GUIDMixin):
@ -313,15 +336,19 @@ class Profile(CreatedAtMixin, HandleMixin, OptionalRawContentMixin, PublicMixin,
raise ValueError("Email is not valid")
class Retraction(CreatedAtMixin, HandleMixin, TargetGUIDMixin):
class Retraction(CreatedAtMixin, HandleMixin, TargetGUIDMixin, EntityTypeMixin):
"""Represents a retraction of content by author."""
entity_type = ""
pass
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._required += ["entity_type"]
def validate_entity_type(self):
"""Ensure type is some entity we know of."""
if self.entity_type not in __all__:
raise ValueError("Entity type %s not recognized." % self.entity_type)
class Share(CreatedAtMixin, HandleMixin, TargetGUIDMixin, GUIDMixin, EntityTypeMixin, OptionalRawContentMixin,
PublicMixin, ProviderDisplayNameMixin, TargetHandleMixin):
"""Represents a share of another entity.
``entity_type`` defaults to "Post" but can be any base entity class name. It should be the class name of the
entity that was shared.
The optional ``raw_content`` can be used for a "quoted share" case where the sharer adds their own note to the
share.
"""
entity_type = "Post"

Wyświetl plik

@ -1,6 +1,7 @@
from lxml import etree
from federation.entities.base import Comment, Post, Reaction, Relationship, Profile, Retraction, BaseEntity, Follow
from federation.entities.base import (
Comment, Post, Reaction, Relationship, Profile, Retraction, BaseEntity, Follow, Share)
from federation.entities.diaspora.utils import format_dt, struct_to_xml, get_base_attributes, add_element_to_doc
from federation.exceptions import SignatureVerificationError
from federation.protocols.diaspora.signatures import verify_relayable_signature, create_relayable_signature
@ -211,3 +212,22 @@ class DiasporaRetraction(DiasporaEntityMixin, Retraction):
index = values.index(value)
return list(DiasporaRetraction.mapped.keys())[index]
return value
class DiasporaReshare(DiasporaEntityMixin, Share):
"""Diaspora Reshare."""
def to_xml(self):
element = etree.Element("reshare")
struct_to_xml(element, [
{"author": self.handle},
{"guid": self.guid},
{"created_at": format_dt(self.created_at)},
{"root_author": self.target_handle},
{"root_guid": self.target_guid},
{"provider_display_name": self.provider_display_name},
{"public": "true" if self.public else "false"},
# Some of our own not in Diaspora protocol
{"raw_content": self.raw_content},
{"entity_type": self.entity_type},
])
return element

Wyświetl plik

@ -3,10 +3,10 @@ from datetime import datetime
from lxml import etree
from federation.entities.base import Image, Relationship, Post, Reaction, Comment, Profile, Retraction, Follow
from federation.entities.base import Image, Relationship, Post, Reaction, Comment, Profile, Retraction, Follow, Share
from federation.entities.diaspora.entities import (
DiasporaPost, DiasporaComment, DiasporaLike, DiasporaRequest, DiasporaProfile, DiasporaRetraction,
DiasporaRelayableMixin, DiasporaContact)
DiasporaRelayableMixin, DiasporaContact, DiasporaReshare)
from federation.protocols.diaspora.signatures import get_element_child_info
from federation.utils.diaspora import retrieve_and_parse_profile
@ -21,11 +21,12 @@ MAPPINGS = {
"profile": DiasporaProfile,
"retraction": DiasporaRetraction,
"contact": DiasporaContact,
"reshare": DiasporaReshare,
}
TAGS = [
# Order is important. Any top level tags should be before possibly child tags
"status_message", "comment", "like", "request", "profile", "retraction", "photo", "contact",
"reshare", "status_message", "comment", "like", "request", "profile", "retraction", "photo", "contact",
]
BOOLEAN_KEYS = (
@ -44,6 +45,7 @@ INTEGER_KEYS = (
"width",
)
def xml_children_as_dict(node):
"""Turn the children of node <xml> into a dict, keyed by tag name.
@ -167,9 +169,9 @@ def transform_attributes(attrs, cls):
transformed["raw_content"] = value
elif key in ["diaspora_handle", "sender_handle", "author"]:
transformed["handle"] = value
elif key in ["recipient_handle", "recipient"]:
elif key in ["recipient_handle", "recipient", "root_author", "root_diaspora_id"]:
transformed["target_handle"] = value
elif key == "parent_guid":
elif key in ["parent_guid", "post_guid", "root_guid"]:
transformed["target_guid"] = value
elif key == "first_name":
transformed["name"] = value
@ -203,8 +205,6 @@ def transform_attributes(attrs, cls):
transformed["linked_type"] = "Post"
elif key == "author_signature":
transformed["signature"] = value
elif key == "post_guid":
transformed["target_guid"] = value
elif key in BOOLEAN_KEYS:
transformed[key] = True if value == "true" else False
elif key in DATETIME_KEYS:
@ -239,7 +239,7 @@ def get_outbound_entity(entity, private_key):
outbound = None
cls = entity.__class__
if cls in [DiasporaPost, DiasporaRequest, DiasporaComment, DiasporaLike, DiasporaProfile, DiasporaRetraction,
DiasporaContact]:
DiasporaContact, DiasporaReshare]:
# Already fine
outbound = entity
elif cls == Post:
@ -259,6 +259,8 @@ def get_outbound_entity(entity, private_key):
outbound = DiasporaProfile.from_base(entity)
elif cls == Retraction:
outbound = DiasporaRetraction.from_base(entity)
elif cls == Share:
outbound = DiasporaReshare.from_base(entity)
if not outbound:
raise ValueError("Don't know how to convert this base entity to Diaspora protocol entities.")
if isinstance(outbound, DiasporaRelayableMixin) and not outbound.signature:

Wyświetl plik

@ -6,9 +6,10 @@ from lxml import etree
from federation.entities.base import Profile
from federation.entities.diaspora.entities import (
DiasporaComment, DiasporaPost, DiasporaLike, DiasporaRequest, DiasporaProfile, DiasporaRetraction,
DiasporaContact)
DiasporaContact, DiasporaReshare)
from federation.entities.diaspora.mappers import message_to_objects
from federation.exceptions import SignatureVerificationError
from federation.tests.factories.entities import ShareFactory
from federation.tests.fixtures.keys import get_dummy_private_key
from federation.tests.fixtures.payloads import DIASPORA_POST_COMMENT
@ -93,6 +94,21 @@ class TestEntitiesConvertToXML:
b"<following>true</following><sharing>true</sharing></contact>"
assert etree.tostring(result) == converted
def test_reshare_to_xml(self):
base_entity = ShareFactory()
entity = DiasporaReshare.from_base(base_entity)
result = entity.to_xml()
assert result.tag == "reshare"
result.find("created_at").text = "" # timestamp makes testing painful
converted = "<reshare><author>%s</author><guid>%s</guid><created_at></created_at><root_author>%s" \
"</root_author><root_guid>%s</root_guid><provider_display_name>%s</provider_display_name>" \
"<public>%s</public><raw_content>%s</raw_content><entity_type>%s</entity_type></reshare>" % (
entity.handle, entity.guid, entity.target_handle, entity.target_guid,
entity.provider_display_name, "true" if entity.public else "false", entity.raw_content,
entity.entity_type,
)
assert etree.tostring(result).decode("utf-8") == converted
class TestDiasporaProfileFillExtraAttributes:
def test_raises_if_no_handle(self):

Wyświetl plik

@ -6,17 +6,18 @@ import pytest
from federation.entities.base import (
Comment, Post, Reaction, Relationship, Profile, Retraction, Image,
Follow)
Follow, Share)
from federation.entities.diaspora.entities import (
DiasporaPost, DiasporaComment, DiasporaLike, DiasporaRequest,
DiasporaProfile, DiasporaRetraction, DiasporaContact)
DiasporaProfile, DiasporaRetraction, DiasporaContact, DiasporaReshare)
from federation.entities.diaspora.mappers import (
message_to_objects, get_outbound_entity, check_sender_and_entity_handle_match)
from federation.tests.fixtures.payloads import (
DIASPORA_POST_SIMPLE, DIASPORA_POST_COMMENT, DIASPORA_POST_LIKE,
DIASPORA_REQUEST, DIASPORA_PROFILE, DIASPORA_POST_INVALID, DIASPORA_RETRACTION,
DIASPORA_POST_WITH_PHOTOS, DIASPORA_POST_LEGACY_TIMESTAMP, DIASPORA_POST_LEGACY, DIASPORA_CONTACT,
DIASPORA_LEGACY_REQUEST_RETRACTION, DIASPORA_POST_WITH_PHOTOS_2, DIASPORA_PROFILE_EMPTY_TAGS)
DIASPORA_LEGACY_REQUEST_RETRACTION, DIASPORA_POST_WITH_PHOTOS_2, DIASPORA_PROFILE_EMPTY_TAGS, DIASPORA_RESHARE,
DIASPORA_RESHARE_WITH_EXTRA_PROPERTIES, DIASPORA_RESHARE_LEGACY)
def mock_fill(attributes):
@ -189,6 +190,40 @@ class TestDiasporaEntityMappersReceive:
assert entity.target_handle == "bob@example.org"
assert entity.following is True
def test_message_to_objects_reshare(self):
entities = message_to_objects(DIASPORA_RESHARE, "alice@example.org")
assert len(entities) == 1
entity = entities[0]
assert isinstance(entity, DiasporaReshare)
assert entity.handle == "alice@example.org"
assert entity.guid == "a0b53e5029f6013487753131731751e9"
assert entity.provider_display_name == ""
assert entity.target_handle == "bob@example.com"
assert entity.target_guid == "a0b53bc029f6013487753131731751e9"
assert entity.public is True
assert entity.entity_type == "Post"
def test_message_to_objects_reshare_legacy(self):
entities = message_to_objects(DIASPORA_RESHARE_LEGACY, "alice@example.org")
assert len(entities) == 1
entity = entities[0]
assert isinstance(entity, DiasporaReshare)
assert entity.handle == "alice@example.org"
assert entity.guid == "a0b53e5029f6013487753131731751e9"
assert entity.provider_display_name == ""
assert entity.target_handle == "bob@example.com"
assert entity.target_guid == "a0b53bc029f6013487753131731751e9"
assert entity.public is True
assert entity.entity_type == "Post"
def test_message_to_objects_reshare_extra_properties(self):
entities = message_to_objects(DIASPORA_RESHARE_WITH_EXTRA_PROPERTIES, "alice@example.org")
assert len(entities) == 1
entity = entities[0]
assert isinstance(entity, DiasporaReshare)
assert entity.raw_content == "Important note here"
assert entity.entity_type == "Comment"
@patch("federation.entities.diaspora.mappers.logger.error")
def test_invalid_entity_logs_an_error(self, mock_logger):
entities = message_to_objects(DIASPORA_POST_INVALID, "alice@alice.diaspora.example.org")
@ -242,6 +277,8 @@ class TestGetOutboundEntity:
assert get_outbound_entity(entity, private_key) == entity
entity = DiasporaContact()
assert get_outbound_entity(entity, private_key) == entity
entity = DiasporaReshare()
assert get_outbound_entity(entity, private_key) == entity
def test_post_is_converted_to_diasporapost(self, private_key):
entity = Post()
@ -283,6 +320,10 @@ class TestGetOutboundEntity:
entity = Follow()
assert isinstance(get_outbound_entity(entity, private_key), DiasporaContact)
def test_share_is_converted_to_diasporareshare(self, private_key):
entity = Share()
assert isinstance(get_outbound_entity(entity, private_key), DiasporaReshare)
def test_signs_relayable_if_no_signature(self, private_key):
entity = DiasporaComment()
outbound = get_outbound_entity(entity, private_key)

Wyświetl plik

@ -4,11 +4,11 @@ import pytest
from federation.entities.base import (
BaseEntity, Relationship, Profile, RawContentMixin, GUIDMixin, HandleMixin, PublicMixin, Image, Retraction,
Follow)
from federation.tests.factories.entities import TaggedPostFactory, PostFactory
Follow, TargetHandleMixin)
from federation.tests.factories.entities import TaggedPostFactory, PostFactory, ShareFactory
class TestPostEntityTags():
class TestPostEntityTags:
def test_post_entity_returns_list_of_tags(self):
post = TaggedPostFactory()
assert post.tags == {"tagone", "tagtwo", "tagthree", "upper", "snakecase"}
@ -18,7 +18,7 @@ class TestPostEntityTags():
assert post.tags == set()
class TestBaseEntityCallsValidateMethods():
class TestBaseEntityCallsValidateMethods:
def test_entity_calls_attribute_validate_method(self):
post = PostFactory()
post.validate_location = Mock()
@ -48,28 +48,41 @@ class TestBaseEntityCallsValidateMethods():
post._validate_children()
class TestGUIDMixinValidate():
class TestGUIDMixinValidate:
def test_validate_guid_raises_on_low_length(self):
guid = GUIDMixin(guid="x"*15)
with pytest.raises(ValueError):
guid.validate()
guid = GUIDMixin(guid="x" * 16)
guid.validate()
class TestHandleMixinValidate():
class TestHandleMixinValidate:
def test_validate_handle_raises_on_invalid_format(self):
handle = HandleMixin(handle="foobar")
with pytest.raises(ValueError):
handle.validate()
handle = HandleMixin(handle="foobar@example.com")
handle.validate()
class TestPublicMixinValidate():
class TestTargetHandleMixinValidate:
def test_validate_target_handle_raises_on_invalid_format(self):
handle = TargetHandleMixin(target_handle="foobar")
with pytest.raises(ValueError):
handle.validate()
handle = TargetHandleMixin(target_handle="foobar@example.com")
handle.validate()
class TestPublicMixinValidate:
def test_validate_public_raises_on_low_length(self):
public = PublicMixin(public="foobar")
with pytest.raises(ValueError):
public.validate()
class TestEntityRequiredAttributes():
class TestEntityRequiredAttributes:
def test_entity_checks_for_required_attributes(self):
entity = BaseEntity()
entity._required = ["foobar"]
@ -85,7 +98,7 @@ class TestEntityRequiredAttributes():
entity.validate()
class TestRelationshipEntity():
class TestRelationshipEntity:
def test_instance_creation(self):
entity = Relationship(handle="bob@example.com", target_handle="alice@example.com", relationship="following")
assert entity
@ -95,13 +108,8 @@ class TestRelationshipEntity():
entity = Relationship(handle="bob@example.com", target_handle="alice@example.com", relationship="hating")
entity.validate()
def test_instance_creation_validates_target_handle_value(self):
with pytest.raises(ValueError):
entity = Relationship(handle="bob@example.com", target_handle="fefle.com", relationship="following")
entity.validate()
class TestProfileEntity():
class TestProfileEntity:
def test_instance_creation(self):
entity = Profile(handle="bob@example.com", raw_content="foobar")
assert entity
@ -117,7 +125,7 @@ class TestProfileEntity():
entity.validate()
class TestImageEntity():
class TestImageEntity:
def test_instance_creation(self):
entity = Image(
guid="x"*16, handle="foo@example.com", public=False, remote_path="foobar", remote_name="barfoo"
@ -137,7 +145,7 @@ class TestImageEntity():
entity.validate()
class TestRetractionEntity():
class TestRetractionEntity:
def test_instance_creation(self):
entity = Retraction(
handle="foo@example.com", target_guid="x"*16, entity_type="Post"
@ -162,16 +170,15 @@ class TestRetractionEntity():
entity.validate()
class TestFollowEntity():
class TestFollowEntity:
def test_instance_creation(self):
entity = Follow(
handle="foo@example.com", target_handle="bar@example.org", following=True
)
entity.validate()
def test_required_validates(self):
entity = Follow(
handle="foo@example.com", following=True
)
with pytest.raises(ValueError):
entity.validate()
class TestShareEntity:
def test_instance_creation(self):
entity = ShareFactory()
entity.validate()

Wyświetl plik

@ -1,9 +1,8 @@
# -*- coding: utf-8 -*-
from random import shuffle
import factory
from factory import fuzzy
from federation.entities.base import Post, Profile
from federation.entities.base import Post, Profile, Share
from federation.entities.diaspora.entities import DiasporaPost
@ -47,3 +46,15 @@ class ProfileFactory(GUIDMixinFactory, HandleMixinFactory, RawContentMixinFactor
name = fuzzy.FuzzyText(length=30)
public_key = fuzzy.FuzzyText(length=300)
class ShareFactory(GUIDMixinFactory, HandleMixinFactory):
class Meta:
model = Share
target_guid = factory.Faker("uuid4")
entity_type = "Post"
raw_content = ""
public = factory.Faker("pybool")
provider_display_name = ""
target_handle = factory.Faker("safe_email")

Wyświetl plik

@ -231,3 +231,41 @@ DIASPORA_CONTACT = """
<sharing>true</sharing>
</contact>
"""
DIASPORA_RESHARE = """
<reshare>
<author>alice@example.org</author>
<guid>a0b53e5029f6013487753131731751e9</guid>
<created_at>2016-07-12T00:36:42Z</created_at>
<provider_display_name/>
<root_author>bob@example.com</root_author>
<root_guid>a0b53bc029f6013487753131731751e9</root_guid>
<public>true</public>
</reshare>
"""
DIASPORA_RESHARE_LEGACY = """
<reshare>
<diaspora_handle>alice@example.org</diaspora_handle>
<guid>a0b53e5029f6013487753131731751e9</guid>
<created_at>2016-07-12T00:36:42Z</created_at>
<provider_display_name/>
<root_diaspora_id>bob@example.com</root_diaspora_id>
<root_guid>a0b53bc029f6013487753131731751e9</root_guid>
<public>true</public>
</reshare>
"""
DIASPORA_RESHARE_WITH_EXTRA_PROPERTIES = """
<reshare>
<author>alice@example.org</author>
<guid>a0b53e5029f6013487753131731751e9</guid>
<created_at>2016-07-12T00:36:42Z</created_at>
<provider_display_name/>
<root_author>bob@example.com</root_author>
<root_guid>a0b53bc029f6013487753131731751e9</root_guid>
<public>true</public>
<raw_content>Important note here</raw_content>
<entity_type>Comment</entity_type>
</reshare>
"""