diff --git a/.gitignore b/.gitignore index d2cba31..0338d18 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ *.sw[op] key_*.pem +.coverage +coverage.xml *.egg-info .pytest_cache .mypy_cache/ diff --git a/.travis.yml b/.travis.yml index b077722..00852c6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,4 +7,5 @@ install: script: # - mypy --ignore-missing-imports . # - flake8 . - - python -m pytest -vv + - black --check + - python -m pytest -vv -cov=little_boxes diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..64ad321 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include README.md LICENSE diff --git a/README.md b/README.md index afd6ca9..0d5bd62 100644 --- a/README.md +++ b/README.md @@ -45,3 +45,9 @@ outbox.post(follow) ## Projects using Little Boxes - [microblog.pub](http://github.com/tsileo/microblog.pub) (using MongoDB as a backend) + +## Contributions + +TODO: document Mypy, flake8 and black. + +PRs are welcome, please open an issue to start a discussion before your start any work. diff --git a/dev-requirements.txt b/dev-requirements.txt index baf71ec..910ba41 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,3 +1,5 @@ pytest +pytest-cov +black flake8 mypy diff --git a/little_boxes/__init__.py b/little_boxes/__init__.py index c30c37d..cdf368d 100644 --- a/little_boxes/__init__.py +++ b/little_boxes/__init__.py @@ -4,9 +4,9 @@ logger = logging.getLogger(__name__) def strtobool(s: str) -> bool: - if s in ['y', 'yes', 'true', 'on', '1']: + if s in ["y", "yes", "true", "on", "1"]: return True - if s in ['n', 'no', 'false', 'off', '0']: + if s in ["n", "no", "false", "off", "0"]: return False - raise ValueError(f'cannot convert {s} to bool') + raise ValueError(f"cannot convert {s} to bool") diff --git a/little_boxes/__version__.py b/little_boxes/__version__.py new file mode 100644 index 0000000..0deec50 --- /dev/null +++ b/little_boxes/__version__.py @@ -0,0 +1,3 @@ +VERSION = (0, 1, 0) + +__version__ = ".".join(map(str, VERSION)) diff --git a/little_boxes/activitypub.py b/little_boxes/activitypub.py index 340481a..9e0b884 100644 --- a/little_boxes/activitypub.py +++ b/little_boxes/activitypub.py @@ -29,24 +29,22 @@ logger = logging.getLogger(__name__) ObjectType = Dict[str, Any] ObjectOrIDType = Union[str, ObjectType] -CTX_AS = 'https://www.w3.org/ns/activitystreams' -CTX_SECURITY = 'https://w3id.org/security/v1' -AS_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public' +CTX_AS = "https://www.w3.org/ns/activitystreams" +CTX_SECURITY = "https://w3id.org/security/v1" +AS_PUBLIC = "https://www.w3.org/ns/activitystreams#Public" COLLECTION_CTX = [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", - { - "Hashtag": "as:Hashtag", - "sensitive": "as:sensitive", - } + {"Hashtag": "as:Hashtag", "sensitive": "as:sensitive"}, ] # Will be used to keep track of all the defined activities -_ACTIVITY_CLS: Dict['ActivityTypeEnum', Type['_BaseActivity']] = {} +_ACTIVITY_CLS: Dict["ActivityTypeEnum", Type["_BaseActivity"]] = {} BACKEND = None + def use_backend(backend_instance): global BACKEND BACKEND = backend_instance @@ -54,31 +52,36 @@ def use_backend(backend_instance): class ActivityType(Enum): """Supported activity `type`.""" - ANNOUNCE = 'Announce' - BLOCK = 'Block' - LIKE = 'Like' - CREATE = 'Create' - UPDATE = 'Update' - PERSON = 'Person' - ORDERED_COLLECTION = 'OrderedCollection' - ORDERED_COLLECTION_PAGE = 'OrderedCollectionPage' - COLLECTION_PAGE = 'CollectionPage' - COLLECTION = 'Collection' - NOTE = 'Note' - ACCEPT = 'Accept' - REJECT = 'Reject' - FOLLOW = 'Follow' - DELETE = 'Delete' - UNDO = 'Undo' - IMAGE = 'Image' - TOMBSTONE = 'Tombstone' + + ANNOUNCE = "Announce" + BLOCK = "Block" + LIKE = "Like" + CREATE = "Create" + UPDATE = "Update" + PERSON = "Person" + ORDERED_COLLECTION = "OrderedCollection" + ORDERED_COLLECTION_PAGE = "OrderedCollectionPage" + COLLECTION_PAGE = "CollectionPage" + COLLECTION = "Collection" + NOTE = "Note" + ACCEPT = "Accept" + REJECT = "Reject" + FOLLOW = "Follow" + DELETE = "Delete" + UNDO = "Undo" + IMAGE = "Image" + TOMBSTONE = "Tombstone" -def parse_activity(payload: ObjectType, expected: Optional[ActivityType] = None) -> 'BaseActivity': - t = ActivityType(payload['type']) +def parse_activity( + payload: ObjectType, expected: Optional[ActivityType] = None +) -> "BaseActivity": + t = ActivityType(payload["type"]) if expected and t != expected: - raise UnexpectedActivityTypeError(f'expected a {expected.name} activity, got a {payload["type"]}') + raise UnexpectedActivityTypeError( + f'expected a {expected.name} activity, got a {payload["type"]}' + ) if t not in _ACTIVITY_CLS: raise BadActivityError(f'unsupported activity type {payload["type"]}') @@ -99,33 +102,36 @@ def clean_activity(activity: ObjectType) -> Dict[str, Any]: """Clean the activity before rendering it. - Remove the hidden bco and bcc field """ - for field in ['bto', 'bcc']: + for field in ["bto", "bcc"]: if field in activity: - del(activity[field]) - if activity['type'] == 'Create' and field in activity['object']: - del(activity['object'][field]) + del (activity[field]) + if activity["type"] == "Create" and field in activity["object"]: + del (activity["object"][field]) return activity def _get_actor_id(actor: ObjectOrIDType) -> str: """Helper for retrieving an actor `id`.""" if isinstance(actor, dict): - return actor['id'] + return actor["id"] return actor # FIXME(tsileo): keeps differents list of each `as_actor`, and uses `as_actor` as first arg for everything. def track_call(f): fname = f.__name__ + def wrapper(*args, **kwargs): args[0]._METHOD_CALLS[args[1].id].append((fname, args, kwargs)) return f(*args, **kwargs) + return wrapper # FIXME(tsileo): move this to little_boxes.tests.InMemBackend and make this one an abstract classes class BaseBackend(object): """In-memory backend meant to be used for the test suite.""" + DB = {} USERS = {} FETCH_MOCK = {} @@ -137,55 +143,55 @@ class BaseBackend(object): # For tests purposes only _METHOD_CALLS = {} - def called_methods(self, p: 'Person') -> List[str]: + def called_methods(self, p: "Person") -> List[str]: data = list(self._METHOD_CALLS[p.id]) self._METHOD_CALLS[p.id] = [] return data - def assert_called_methods(self, p: 'Person', *asserts) -> List[str]: + def assert_called_methods(self, p: "Person", *asserts) -> List[str]: calls = self.called_methods(p) for i, assert_data in enumerate(asserts): - if len(calls) < i+1: - raise ValueError(f'no methods called at step #{i}') + if len(calls) < i + 1: + raise ValueError(f"no methods called at step #{i}") error_msg, name, *funcs = assert_data if name != calls[i][0]: - raise ValueError(f'expected method {name} to be called at step #{i}, but got {calls[i][0]}') - if len(funcs) < len(calls[i][1])-1: - raise ValueError(f'args left unchecked for method {name} at step #{i}') + raise ValueError( + f"expected method {name} to be called at step #{i}, but got {calls[i][0]}" + ) + if len(funcs) < len(calls[i][1]) - 1: + raise ValueError(f"args left unchecked for method {name} at step #{i}") for z, f in enumerate(funcs): - if len(calls[i][1]) < z+2: # XXX(tsileo): 0 will be self - raise ValueError(f'method {name} has no args at index {Z}') + if len(calls[i][1]) < z + 2: # XXX(tsileo): 0 will be self + raise ValueError(f"method {name} has no args at index {Z}") try: - f(calls[i][1][z+1]) + f(calls[i][1][z + 1]) except AssertionError as ae: ae.args = ((error_msg),) raise ae if len(asserts) < len(calls): - raise ValueError(f'expecting {len(calls)} assertion, only got {len(asserts)}, leftover: {calls[len(asserts):]!r}') - + raise ValueError( + f"expecting {len(calls)} assertion, only got {len(asserts)}, leftover: {calls[len(asserts):]!r}" + ) + return calls def random_object_id(self) -> str: """Generates a random object ID.""" - return binascii.hexlify(os.urandom(8)).decode('utf-8') - + return binascii.hexlify(os.urandom(8)).decode("utf-8") def setup_actor(self, name, pusername): """Create a new actor in this backend.""" p = Person( name=name, preferredUsername=pusername, - summary='Hello', - id=f'https://lol.com/{pusername}', - inbox=f'https://lol.com/{pusername}/inbox', + summary="Hello", + id=f"https://lol.com/{pusername}", + inbox=f"https://lol.com/{pusername}/inbox", ) - + self.USERS[p.preferredUsername] = p - self.DB[p.id] = { - 'inbox': [], - 'outbox': [], - } + self.DB[p.id] = {"inbox": [], "outbox": []} self.INBOX_IDX[p.id] = {} self.OUTBOX_IDX[p.id] = {} self.FOLLOWERS[p.id] = [] @@ -196,134 +202,139 @@ class BaseBackend(object): def fetch_iri(self, iri: str): return self.FETCH_MOCK[iri] - - def get_user(self, username: str) -> 'Person': + + def get_user(self, username: str) -> "Person": if username in self.USERS: return self.USERS[username] else: - raise ValueError(f'bad username {username}') + raise ValueError(f"bad username {username}") @track_call - def outbox_is_blocked(self, as_actor: 'Person', actor_id: str) -> bool: + def outbox_is_blocked(self, as_actor: "Person", actor_id: str) -> bool: """Returns True if `as_actor` has blocked `actor_id`.""" - for activity in self.DB[as_actor.id]['outbox']: + for activity in self.DB[as_actor.id]["outbox"]: if activity.ACTIVITY_TYPE == ActivityType.BLOCK: return True return False - def inbox_get_by_iri(self, as_actor: 'Person', iri: str) -> Optional['BaseActivity']: - for activity in self.DB[as_actor.id]['inbox']: + def inbox_get_by_iri( + self, as_actor: "Person", iri: str + ) -> Optional["BaseActivity"]: + for activity in self.DB[as_actor.id]["inbox"]: if activity.id == iri: return activity return None @track_call - def inbox_new(self, as_actor: 'Person', activity: 'BaseActivity') -> None: + def inbox_new(self, as_actor: "Person", activity: "BaseActivity") -> None: if activity.id in self.INBOX_IDX[as_actor.id]: return - self.DB[as_actor.id]['inbox'].append(activity) + self.DB[as_actor.id]["inbox"].append(activity) self.INBOX_IDX[as_actor.id][activity.id] = activity def activity_url(self, obj_id: str) -> str: # from the random hex ID - return 'TODO' + return "TODO" @track_call - def outbox_new(self, as_actor: 'Person', activity: 'BaseActivity') -> None: - print(f'saving {activity!r} to DB') + def outbox_new(self, as_actor: "Person", activity: "BaseActivity") -> None: + print(f"saving {activity!r} to DB") actor_id = activity.get_actor().id if activity.id in self.OUTBOX_IDX[actor_id]: return - self.DB[actor_id]['outbox'].append(activity) + self.DB[actor_id]["outbox"].append(activity) self.OUTBOX_IDX[actor_id][activity.id] = activity @track_call - def new_follower(self, as_actor: 'Person', follow: 'Follow') -> None: + def new_follower(self, as_actor: "Person", follow: "Follow") -> None: self.FOLLOWERS[follow.get_object().id].append(follow.get_actor().id) @track_call - def undo_new_follower(self, as_actor: 'Person', follow: 'Follow') -> None: + def undo_new_follower(self, as_actor: "Person", follow: "Follow") -> None: self.FOLLOWERS[follow.get_object().id].remove(follow.get_actor().id) @track_call - def new_following(self, as_actor: 'Person', follow: 'Follow') -> None: - print(f'new following {follow!r}') + def new_following(self, as_actor: "Person", follow: "Follow") -> None: + print(f"new following {follow!r}") self.FOLLOWING[as_actor.id].append(follow.get_object().id) @track_call - def undo_new_following(self, as_actor: 'Person', follow: 'Follow') -> None: + def undo_new_following(self, as_actor: "Person", follow: "Follow") -> None: self.FOLLOWING[as_actor.id].remove(follow.get_object().id) - def followers(self, as_actor: 'Person') -> List[str]: + def followers(self, as_actor: "Person") -> List[str]: return self.FOLLOWERS[as_actor.id] - def following(self, as_actor: 'Person') -> List[str]: + def following(self, as_actor: "Person") -> List[str]: return self.FOLLOWING[as_actor.id] @track_call - def post_to_remote_inbox(self, as_actor: 'Person', payload_encoded: str, recp: str) -> None: + def post_to_remote_inbox( + self, as_actor: "Person", payload_encoded: str, recp: str + ) -> None: payload = json.loads(payload_encoded) - print(f'post_to_remote_inbox {payload} {recp}') + print(f"post_to_remote_inbox {payload} {recp}") act = parse_activity(payload) - as_actor = parse_activity(self.fetch_iri(recp.replace('/inbox', ''))) + as_actor = parse_activity(self.fetch_iri(recp.replace("/inbox", ""))) act.process_from_inbox(as_actor) - def is_from_outbox(self, activity: 'BaseActivity') -> bool: + def is_from_outbox(self, activity: "BaseActivity") -> bool: # return as_actor.id == activity.get_actor().id return True # FIXME(tsileo): implement this - def inbox_like(self, activity: 'Like') -> None: + def inbox_like(self, activity: "Like") -> None: pass - def inbox_undo_like(self, activity: 'Like') -> None: + def inbox_undo_like(self, activity: "Like") -> None: pass - def outbox_like(self, activity: 'Like') -> None: + def outbox_like(self, activity: "Like") -> None: pass - def outbox_undo_like(self, activity: 'Lke') -> None: + def outbox_undo_like(self, activity: "Lke") -> None: pass - def inbox_announce(self, activity: 'Announce') -> None: + def inbox_announce(self, activity: "Announce") -> None: pass - def inbox_undo_announce(self, activity: 'Announce') -> None: + def inbox_undo_announce(self, activity: "Announce") -> None: pass - def outbox_announce(self, activity: 'Announce') -> None: + def outbox_announce(self, activity: "Announce") -> None: pass - def outbox_undo_announce(self, activity: 'Announce') -> None: + def outbox_undo_announce(self, activity: "Announce") -> None: pass - def inbox_delete(self, activity: 'Delete') -> None: + def inbox_delete(self, activity: "Delete") -> None: pass - def outbox_delete(self, activity: 'Delete') -> None: + def outbox_delete(self, activity: "Delete") -> None: pass - def inbox_update(self, activity: 'Update') -> None: + def inbox_update(self, activity: "Update") -> None: pass - def outbox_update(self, activity: 'Update') -> None: + def outbox_update(self, activity: "Update") -> None: pass - def inbox_create(self, activity: 'Create') -> None: + def inbox_create(self, activity: "Create") -> None: pass - def outbox_create(self, activity: 'Create') -> None: + def outbox_create(self, activity: "Create") -> None: pass class _ActivityMeta(type): """Metaclass for keeping track of subclass.""" + def __new__(meta, name, bases, class_dict): cls = type.__new__(meta, name, bases, class_dict) # Ensure the class has an activity type defined - if name != 'BaseActivity' and not cls.ACTIVITY_TYPE: - raise ValueError(f'class {name} has no ACTIVITY_TYPE') + if name != "BaseActivity" and not cls.ACTIVITY_TYPE: + raise ValueError(f"class {name} has no ACTIVITY_TYPE") # Register it _ACTIVITY_CLS[cls.ACTIVITY_TYPE] = cls @@ -333,83 +344,91 @@ class _ActivityMeta(type): class BaseActivity(object, metaclass=_ActivityMeta): """Base class for ActivityPub activities.""" - ACTIVITY_TYPE: Optional[ActivityType] = None # the ActivityTypeEnum the class will represent + ACTIVITY_TYPE: Optional[ + ActivityType + ] = None # the ActivityTypeEnum the class will represent OBJECT_REQUIRED = False # Whether the object field is required or note ALLOWED_OBJECT_TYPES: List[ActivityType] = [] - ACTOR_REQUIRED = True # Most of the object requires an actor, so this flag in on by default + ACTOR_REQUIRED = ( + True + ) # Most of the object requires an actor, so this flag in on by default def __init__(self, **kwargs) -> None: - if kwargs.get('type') and kwargs.pop('type') != self.ACTIVITY_TYPE.value: - raise UnexpectedActivityTypeError(f'Expect the type to be {self.ACTIVITY_TYPE.value!r}') + if kwargs.get("type") and kwargs.pop("type") != self.ACTIVITY_TYPE.value: + raise UnexpectedActivityTypeError( + f"Expect the type to be {self.ACTIVITY_TYPE.value!r}" + ) # Initialize the dict that will contains all the activity fields - self._data: Dict[str, Any] = { - 'type': self.ACTIVITY_TYPE.value - } - logger.debug(f'initializing a {self.ACTIVITY_TYPE.value} activity: {kwargs!r}') + self._data: Dict[str, Any] = {"type": self.ACTIVITY_TYPE.value} + logger.debug(f"initializing a {self.ACTIVITY_TYPE.value} activity: {kwargs!r}") # A place to set ephemeral data self.__ctx = {} # The id may not be present for new activities - if 'id' in kwargs: - self._data['id'] = kwargs.pop('id') + if "id" in kwargs: + self._data["id"] = kwargs.pop("id") if self.ACTIVITY_TYPE != ActivityType.PERSON and self.ACTOR_REQUIRED: - actor = kwargs.get('actor') + actor = kwargs.get("actor") if actor: - kwargs.pop('actor') + kwargs.pop("actor") actor = self._validate_person(actor) - self._data['actor'] = actor + self._data["actor"] = actor else: - raise BadActivityError('missing actor') + raise BadActivityError("missing actor") - if self.OBJECT_REQUIRED and 'object' in kwargs: - obj = kwargs.pop('object') + if self.OBJECT_REQUIRED and "object" in kwargs: + obj = kwargs.pop("object") if isinstance(obj, str): # The object is a just a reference the its ID/IRI # FIXME(tsileo): fetch the ref - self._data['object'] = obj + self._data["object"] = obj else: if not self.ALLOWED_OBJECT_TYPES: - raise UnexpectedActivityTypeError('unexpected object') - if 'type' not in obj or (self.ACTIVITY_TYPE != ActivityType.CREATE and 'id' not in obj): - raise BadActivityError('invalid object, missing type') - if ActivityType(obj['type']) not in self.ALLOWED_OBJECT_TYPES: + raise UnexpectedActivityTypeError("unexpected object") + if "type" not in obj or ( + self.ACTIVITY_TYPE != ActivityType.CREATE and "id" not in obj + ): + raise BadActivityError("invalid object, missing type") + if ActivityType(obj["type"]) not in self.ALLOWED_OBJECT_TYPES: raise UnexpectedActivityTypeError( f'unexpected object type {obj["type"]} (allowed={self.ALLOWED_OBJECT_TYPES!r})' ) - self._data['object'] = obj + self._data["object"] = obj - if '@context' not in kwargs: - self._data['@context'] = CTX_AS + if "@context" not in kwargs: + self._data["@context"] = CTX_AS else: - self._data['@context'] = kwargs.pop('@context') + self._data["@context"] = kwargs.pop("@context") # @context check - if not isinstance(self._data['@context'], list): - self._data['@context'] = [self._data['@context']] - if CTX_SECURITY not in self._data['@context']: - self._data['@context'].append(CTX_SECURITY) - if isinstance(self._data['@context'][-1], dict): - self._data['@context'][-1]['Hashtag'] = 'as:Hashtag' - self._data['@context'][-1]['sensitive'] = 'as:sensitive' + if not isinstance(self._data["@context"], list): + self._data["@context"] = [self._data["@context"]] + if CTX_SECURITY not in self._data["@context"]: + self._data["@context"].append(CTX_SECURITY) + if isinstance(self._data["@context"][-1], dict): + self._data["@context"][-1]["Hashtag"] = "as:Hashtag" + self._data["@context"][-1]["sensitive"] = "as:sensitive" else: - self._data['@context'].append({'Hashtag': 'as:Hashtag', 'sensitive': 'as:sensitive'}) + self._data["@context"].append( + {"Hashtag": "as:Hashtag", "sensitive": "as:sensitive"} + ) # FIXME(tsileo): keys required for some subclasses? allowed_keys = None try: allowed_keys = self._init(**kwargs) - logger.debug('calling custom init') + logger.debug("calling custom init") except NotImplementedError: pass if allowed_keys: # Allows an extra to (like for Accept and Follow) - kwargs.pop('to', None) + kwargs.pop("to", None) if len(set(kwargs.keys()) - set(allowed_keys)) > 0: - raise BadActivityError(f'extra data left: {kwargs!r}') + raise BadActivityError(f"extra data left: {kwargs!r}") else: # Remove keys with `None` value valid_kwargs = {} @@ -431,11 +450,11 @@ class BaseActivity(object, metaclass=_ActivityMeta): def __repr__(self) -> str: """Pretty repr.""" - return '{}({!r})'.format(self.__class__.__qualname__, self._data.get('id')) + return "{}({!r})".format(self.__class__.__qualname__, self._data.get("id")) def __str__(self) -> str: """Returns the ID/IRI when castign to str.""" - return str(self._data.get('id', f'[new {self.ACTIVITY_TYPE} activity]')) + return str(self._data.get("id", f"[new {self.ACTIVITY_TYPE} activity]")) def __getattr__(self, name: str) -> Any: """Allow to access the object field as regular attributes.""" @@ -448,18 +467,18 @@ class BaseActivity(object, metaclass=_ActivityMeta): def outbox_set_id(self, uri: str, obj_id: str) -> None: """Set the ID for a new activity.""" - logger.debug(f'setting ID {uri} / {obj_id}') - self._data['id'] = uri + logger.debug(f"setting ID {uri} / {obj_id}") + self._data["id"] = uri try: self._outbox_set_id(uri, obj_id) except NotImplementedError: pass def _actor_id(self, obj: ObjectOrIDType) -> str: - if isinstance(obj, dict) and obj['type'] == ActivityType.PERSON.value: - obj_id = obj.get('id') + if isinstance(obj, dict) and obj["type"] == ActivityType.PERSON.value: + obj_id = obj.get("id") if not obj_id: - raise BadActivityError(f'missing object id: {obj!r}') + raise BadActivityError(f"missing object id: {obj!r}") return obj_id elif isinstance(obj, str): return obj @@ -471,55 +490,65 @@ class BaseActivity(object, metaclass=_ActivityMeta): try: actor = BACKEND.fetch_iri(obj_id) except Exception: - raise BadActivityError(f'failed to validate actor {obj!r}') + raise BadActivityError(f"failed to validate actor {obj!r}") - if not actor or 'id' not in actor: - raise BadActivityError(f'invalid actor {actor}') + if not actor or "id" not in actor: + raise BadActivityError(f"invalid actor {actor}") - return actor['id'] + return actor["id"] - def get_object(self) -> 'BaseActivity': + def get_object(self) -> "BaseActivity": """Returns the object as a BaseActivity instance.""" if self.__obj: return self.__obj - if isinstance(self._data['object'], dict): - p = parse_activity(self._data['object']) + if isinstance(self._data["object"], dict): + p = parse_activity(self._data["object"]) else: - obj = BACKEND.fetch_iri(self._data['object']) - if ActivityType(obj.get('type')) not in self.ALLOWED_OBJECT_TYPES: - raise UnexpectedActivityTypeError(f'invalid object type {obj.get("type")!r}') + obj = BACKEND.fetch_iri(self._data["object"]) + if ActivityType(obj.get("type")) not in self.ALLOWED_OBJECT_TYPES: + raise UnexpectedActivityTypeError( + f'invalid object type {obj.get("type")!r}' + ) p = parse_activity(obj) - self.__obj: Optional['BaseActivity'] = p + self.__obj: Optional["BaseActivity"] = p return p def reset_object_cache(self) -> None: self.__obj = None - def to_dict(self, embed: bool = False, embed_object_id_only: bool = False) -> ObjectType: + def to_dict( + self, embed: bool = False, embed_object_id_only: bool = False + ) -> ObjectType: """Serializes the activity back to a dict, ready to be JSON serialized.""" data = dict(self._data) if embed: - for k in ['@context', 'signature']: + for k in ["@context", "signature"]: if k in data: - del(data[k]) - if data.get('object') and embed_object_id_only and isinstance(data['object'], dict): + del (data[k]) + if ( + data.get("object") + and embed_object_id_only + and isinstance(data["object"], dict) + ): try: - data['object'] = data['object']['id'] + data["object"] = data["object"]["id"] except KeyError: - raise BadActivityError(f'embedded object {data["object"]!r} should have an id') + raise BadActivityError( + f'embedded object {data["object"]!r} should have an id' + ) return data - def get_actor(self) -> 'BaseActivity': + def get_actor(self) -> "BaseActivity": # FIXME(tsileo): cache the actor (same way as get_object) - actor = self._data.get('actor') + actor = self._data.get("actor") if not actor and self.ACTOR_REQUIRED: # Quick hack for Note objects if self.ACTIVITY_TYPE == ActivityType.NOTE: - actor = str(self._data.get('attributedTo')) + actor = str(self._data.get("attributedTo")) else: - raise BadActivityError(f'failed to fetch actor: {self._data!r}') + raise BadActivityError(f"failed to fetch actor: {self._data!r}") actor_id = self._actor_id(actor) return Person(**BACKEND.fetch_iri(actor_id)) @@ -527,54 +556,58 @@ class BaseActivity(object, metaclass=_ActivityMeta): def _pre_post_to_outbox(self) -> None: raise NotImplementedError - def _post_to_outbox(self, obj_id: str, activity: ObjectType, recipients: List[str]) -> None: + def _post_to_outbox( + self, obj_id: str, activity: ObjectType, recipients: List[str] + ) -> None: raise NotImplementedError def _undo_outbox(self) -> None: raise NotImplementedError - def _pre_process_from_inbox(self, as_actor: 'Person') -> None: + def _pre_process_from_inbox(self, as_actor: "Person") -> None: raise NotImplementedError - def _process_from_inbox(self, as_actor: 'Person') -> None: + def _process_from_inbox(self, as_actor: "Person") -> None: raise NotImplementedError def _undo_inbox(self) -> None: raise NotImplementedError - def process_from_inbox(self, as_actor: 'Person') -> None: + def process_from_inbox(self, as_actor: "Person") -> None: """Process the message posted to `as_actor` inbox.""" - logger.debug(f'calling main process from inbox hook for {self}') + logger.debug(f"calling main process from inbox hook for {self}") actor = self.get_actor() # Check for Block activity if BACKEND.outbox_is_blocked(as_actor, actor.id): # TODO(tsileo): raise ActorBlockedError? - logger.info(f'actor {actor!r} is blocked, dropping the received activity {self!r}') + logger.info( + f"actor {actor!r} is blocked, dropping the received activity {self!r}" + ) return if BACKEND.inbox_get_by_iri(as_actor, self.id): # The activity is already in the inbox - logger.info(f'received duplicate activity {self}, dropping it') + logger.info(f"received duplicate activity {self}, dropping it") return try: self._pre_process_from_inbox(as_actor) - logger.debug('called pre process from inbox hook') + logger.debug("called pre process from inbox hook") except NotImplementedError: - logger.debug('pre process from inbox hook not implemented') + logger.debug("pre process from inbox hook not implemented") BACKEND.inbox_new(as_actor, self) - logger.info('activity {self!r} saved') + logger.info("activity {self!r} saved") try: self._process_from_inbox(as_actor) - logger.debug('called process from inbox hook') + logger.debug("called process from inbox hook") except NotImplementedError: - logger.debug('process from inbox hook not implemented') + logger.debug("process from inbox hook not implemented") def post_to_outbox(self) -> None: - logger.debug(f'calling main post to outbox hook for {self}') + logger.debug(f"calling main post to outbox hook for {self}") # Assign create a random ID obj_id = BACKEND.random_object_id() @@ -582,25 +615,25 @@ class BaseActivity(object, metaclass=_ActivityMeta): try: self._pre_post_to_outbox() - logger.debug(f'called pre post to outbox hook') + logger.debug(f"called pre post to outbox hook") except NotImplementedError: - logger.debug('pre post to outbox hook not implemented') + logger.debug("pre post to outbox hook not implemented") BACKEND.outbox_new(self.get_actor(), self) recipients = self.recipients() - logger.info(f'recipients={recipients}') + logger.info(f"recipients={recipients}") activity = clean_activity(self.to_dict()) try: self._post_to_outbox(obj_id, activity, recipients) - logger.debug(f'called post to outbox hook') + logger.debug(f"called post to outbox hook") except NotImplementedError: - logger.debug('post to outbox hook not implemented') + logger.debug("post to outbox hook not implemented") payload = json.dumps(activity) for recp in recipients: - logger.debug(f'posting to {recp}') + logger.debug(f"posting to {recp}") BACKEND.post_to_remote_inbox(self.get_actor(), payload, recp) @@ -625,11 +658,11 @@ class BaseActivity(object, metaclass=_ActivityMeta): actor = recipient else: raw_actor = BACKEND.fetch_iri(recipient) - if raw_actor['type'] == ActivityType.PERSON.value: + if raw_actor["type"] == ActivityType.PERSON.value: actor = Person(**raw_actor) if actor.endpoints: - shared_inbox = actor.endpoints.get('sharedInbox') + shared_inbox = actor.endpoints.get("sharedInbox") if shared_inbox not in out: out.append(shared_inbox) continue @@ -638,32 +671,34 @@ class BaseActivity(object, metaclass=_ActivityMeta): out.append(actor.inbox) # Is the activity a `Collection`/`OrderedCollection`? - elif raw_actor['type'] in [ActivityType.COLLECTION.value, - ActivityType.ORDERED_COLLECTION.value]: - for item in parse_collection(raw_actor): - if item in [actor_id, AS_PUBLIC]: - continue - try: - col_actor = Person(**BACKEND.fetch_iri(item)) - except UnexpectedActivityTypeError: - logger.exception(f'failed to fetch actor {item!r}') + elif raw_actor["type"] in [ + ActivityType.COLLECTION.value, + ActivityType.ORDERED_COLLECTION.value, + ]: + for item in parse_collection(raw_actor): + if item in [actor_id, AS_PUBLIC]: + continue + try: + col_actor = Person(**BACKEND.fetch_iri(item)) + except UnexpectedActivityTypeError: + logger.exception(f"failed to fetch actor {item!r}") - if col_actor.endpoints: - shared_inbox = col_actor.endpoints.get('sharedInbox') - if shared_inbox not in out: - out.append(shared_inbox) - continue - if col_actor.inbox and col_actor.inbox not in out: - out.append(col_actor.inbox) + if col_actor.endpoints: + shared_inbox = col_actor.endpoints.get("sharedInbox") + if shared_inbox not in out: + out.append(shared_inbox) + continue + if col_actor.inbox and col_actor.inbox not in out: + out.append(col_actor.inbox) else: - raise BadActivityError(f'failed to parse {raw_actor!r}') + raise BadActivityError(f"failed to parse {raw_actor!r}") return out - def build_undo(self) -> 'BaseActivity': + def build_undo(self) -> "BaseActivity": raise NotImplementedError - def build_delete(self) -> 'BaseActivity': + def build_delete(self) -> "BaseActivity": raise NotImplementedError @@ -691,12 +726,10 @@ class Image(BaseActivity): ACTOR_REQUIRED = False def _init(self, **kwargs): - self._data.update( - url=kwargs.pop('url'), - ) + self._data.update(url=kwargs.pop("url")) def __repr__(self): - return 'Image({!r})'.format(self._data.get('url')) + return "Image({!r})".format(self._data.get("url")) class Follow(BaseActivity): @@ -707,17 +740,14 @@ class Follow(BaseActivity): def _build_reply(self, reply_type: ActivityType) -> BaseActivity: if reply_type == ActivityType.ACCEPT: - return Accept( - actor=self.get_object().id, - object=self.to_dict(embed=True), - ) + return Accept(actor=self.get_object().id, object=self.to_dict(embed=True)) - raise ValueError(f'type {reply_type} is invalid for building a reply') + raise ValueError(f"type {reply_type} is invalid for building a reply") def _recipients(self) -> List[str]: return [self.get_object().id] - def _process_from_inbox(self, as_actor: 'Person') -> None: + def _process_from_inbox(self, as_actor: "Person") -> None: """Receiving a Follow should trigger an Accept.""" accept = self.build_accept() accept.post_to_outbox() @@ -726,7 +756,9 @@ class Follow(BaseActivity): BACKEND.new_follower(self.get_object(), self) - def _post_to_outbox(self, obj_id: str, activity: ObjectType, recipients: List[str]) -> None: + def _post_to_outbox( + self, obj_id: str, activity: ObjectType, recipients: List[str] + ) -> None: # XXX The new_following event will be triggered by Accept pass @@ -740,10 +772,7 @@ class Follow(BaseActivity): return self._build_reply(ActivityType.ACCEPT) def build_undo(self) -> BaseActivity: - return Undo( - object=self.to_dict(embed=True), - actor=self.get_actor().id, - ) + return Undo(object=self.to_dict(embed=True), actor=self.get_actor().id) class Accept(BaseActivity): @@ -755,17 +784,21 @@ class Accept(BaseActivity): def _recipients(self) -> List[str]: return [self.get_object().get_actor().id] - def _pre_process_from_inbox(self, as_actor: 'Person') -> None: + def _pre_process_from_inbox(self, as_actor: "Person") -> None: # FIXME(tsileo): ensure the actor match the object actor pass - def _process_from_inbox(self, as_actor: 'Person') -> None: + def _process_from_inbox(self, as_actor: "Person") -> None: BACKEND.new_following(as_actor, self.get_object()) class Undo(BaseActivity): ACTIVITY_TYPE = ActivityType.UNDO - ALLOWED_OBJECT_TYPES = [ActivityType.FOLLOW, ActivityType.LIKE, ActivityType.ANNOUNCE] + ALLOWED_OBJECT_TYPES = [ + ActivityType.FOLLOW, + ActivityType.LIKE, + ActivityType.ANNOUNCE, + ] OBJECT_REQUIRED = True ACTOR_REQUIRED = True @@ -776,16 +809,16 @@ class Undo(BaseActivity): else: return [obj.get_object().get_actor().id] # TODO(tsileo): handle like and announce - raise Exception('TODO') + raise Exception("TODO") - def _pre_process_from_inbox(self, as_actor: 'Person') -> None: + def _pre_process_from_inbox(self, as_actor: "Person") -> None: """Ensures an Undo activity comes from the same actor as the updated activity.""" obj = self.get_object() actor = self.get_actor() if actor.id != obj.get_actor().id: - raise BadActivityError(f'{actor!r} cannot update {obj!r}') + raise BadActivityError(f"{actor!r} cannot update {obj!r}") - def _process_from_inbox(self, as_actor: 'Person') -> None: + def _process_from_inbox(self, as_actor: "Person") -> None: obj = self.get_object() # FIXME(tsileo): move this to _undo_inbox impl # DB.inbox.update_one({'remote_id': obj.id}, {'$set': {'meta.undo': True}}) @@ -799,22 +832,24 @@ class Undo(BaseActivity): """Ensures an Undo activity references an activity owned by the instance.""" # ABC if not BACKEND.is_from_outbox(self): - raise NotFromOutboxError(f'object {self!r} is not owned by this instance') + raise NotFromOutboxError(f"object {self!r} is not owned by this instance") - def _post_to_outbox(self, obj_id: str, activity: ObjectType, recipients: List[str]) -> None: - logger.debug('processing undo to outbox') - logger.debug('self={}'.format(self)) + def _post_to_outbox( + self, obj_id: str, activity: ObjectType, recipients: List[str] + ) -> None: + logger.debug("processing undo to outbox") + logger.debug("self={}".format(self)) obj = self.get_object() - logger.debug('obj={}'.format(obj)) + logger.debug("obj={}".format(obj)) # FIXME(tsileo): move this to _undo_inbox impl # DB.outbox.update_one({'remote_id': obj.id}, {'$set': {'meta.undo': True}}) try: obj._undo_outbox() - logger.debug(f'_undo_outbox called for {obj}') + logger.debug(f"_undo_outbox called for {obj}") except NotImplementedError: - logger.debug(f'_undo_outbox not implemented for {obj}') + logger.debug(f"_undo_outbox not implemented for {obj}") pass @@ -827,7 +862,7 @@ class Like(BaseActivity): def _recipients(self) -> List[str]: return [self.get_object().get_actor().id] - def _process_from_inbox(self, as_actor: 'Person') -> None: + def _process_from_inbox(self, as_actor: "Person") -> None: # ABC self.inbox_like(self) @@ -859,14 +894,16 @@ class Announce(BaseActivity): def _recipients(self) -> List[str]: recipients = [] - for field in ['to', 'cc']: + for field in ["to", "cc"]: if field in self._data: recipients.extend(_to_list(self._data[field])) return recipients - def _process_from_inbox(self, as_actor: 'Person') -> None: - if isinstance(self._data['object'], str) and not self._data['object'].startswith('http'): + def _process_from_inbox(self, as_actor: "Person") -> None: + if isinstance(self._data["object"], str) and not self._data[ + "object" + ].startswith("http"): # TODO(tsileo): actually drop it without storing it and better logging, also move the check somewhere else logger.warn( f'received an Annouce referencing an OStatus notice ({self._data["object"]}), dropping the message' @@ -880,7 +917,9 @@ class Announce(BaseActivity): # ABC self.inbox_undo_annnounce(self) - def _post_to_outbox(self, obj_id: str, activity: ObjectType, recipients: List[str]) -> None: + def _post_to_outbox( + self, obj_id: str, activity: ObjectType, recipients: List[str] + ) -> None: # ABC self.outbox_announce(self) @@ -908,14 +947,14 @@ class Delete(BaseActivity): obj = self._get_actual_object() return obj._recipients() - def _pre_process_from_inbox(self, as_actor: 'Person') -> None: + def _pre_process_from_inbox(self, as_actor: "Person") -> None: """Ensures a Delete activity comes from the same actor as the deleted activity.""" obj = self._get_actual_object() actor = self.get_actor() if actor.id != obj.get_actor().id: - raise BadActivityError(f'{actor!r} cannot delete {obj!r}') + raise BadActivityError(f"{actor!r} cannot delete {obj!r}") - def _process_from_inbox(self, as_actor: 'Person') -> None: + def _process_from_inbox(self, as_actor: "Person") -> None: # ABC self.inbox_delete(self) # FIXME(tsileo): handle the delete_threads here? @@ -925,9 +964,13 @@ class Delete(BaseActivity): obj = self._get_actual_object() # ABC if not BACKEND.is_from_outbox(self): - raise NotFromOutboxError(f'object {obj["id"]} is not owned by this instance') + raise NotFromOutboxError( + f'object {obj["id"]} is not owned by this instance' + ) - def _post_to_outbox(self, obj_id: str, activity: ObjectType, recipients: List[str]) -> None: + def _post_to_outbox( + self, obj_id: str, activity: ObjectType, recipients: List[str] + ) -> None: # ABC self.outbox_delete(self) @@ -938,23 +981,25 @@ class Update(BaseActivity): OBJECT_REQUIRED = True ACTOR_REQUIRED = True - def _pre_process_from_inbox(self, as_actor: 'Person') -> None: + def _pre_process_from_inbox(self, as_actor: "Person") -> None: """Ensures an Update activity comes from the same actor as the updated activity.""" obj = self.get_object() actor = self.get_actor() if actor.id != obj.get_actor().id: - raise BadActivityError(f'{actor!r} cannot update {obj!r}') + raise BadActivityError(f"{actor!r} cannot update {obj!r}") - def _process_from_inbox(self, as_actor: 'Person') -> None: + def _process_from_inbox(self, as_actor: "Person") -> None: # ABC self.inbox_update(self) def _pre_post_to_outbox(self) -> None: # ABC if not self.is_form_outbox(self): - raise NotFromOutboxError(f'object {self!r} is not owned by this instance') + raise NotFromOutboxError(f"object {self!r} is not owned by this instance") - def _post_to_outbox(self, obj_id: str, activity: ObjectType, recipients: List[str]) -> None: + def _post_to_outbox( + self, obj_id: str, activity: ObjectType, recipients: List[str] + ) -> None: # ABC self.outbox_update(self) @@ -966,27 +1011,27 @@ class Create(BaseActivity): ACTOR_REQUIRED = True def _set_id(self, uri: str, obj_id: str) -> None: - self._data['object']['id'] = uri + '/activity' + self._data["object"]["id"] = uri + "/activity" # ABC - self._data['object']['url'] = self.note_url(self) + self._data["object"]["url"] = self.note_url(self) self.reset_object_cache() def _init(self, **kwargs): obj = self.get_object() if not obj.attributedTo: - self._data['object']['attributedTo'] = self.get_actor().id + self._data["object"]["attributedTo"] = self.get_actor().id if not obj.published: if self.published: - self._data['object']['published'] = self.published + self._data["object"]["published"] = self.published else: - now = datetime.utcnow().replace(microsecond=0).isoformat() + 'Z' - self._data['published'] = now - self._data['object']['published'] = now + now = datetime.utcnow().replace(microsecond=0).isoformat() + "Z" + self._data["published"] = now + self._data["object"]["published"] = now def _recipients(self) -> List[str]: # TODO(tsileo): audience support? recipients = [] - for field in ['to', 'cc', 'bto', 'bcc']: + for field in ["to", "cc", "bto", "bcc"]: if field in self._data: recipients.extend(_to_list(self._data[field])) @@ -994,11 +1039,13 @@ class Create(BaseActivity): return recipients - def _process_from_inbox(self, as_actor: 'Person') -> None: + def _process_from_inbox(self, as_actor: "Person") -> None: # ABC self.inbox_create(self) - def _post_to_outbox(self, obj_id: str, activity: ObjectType, recipients: List[str]) -> None: + def _post_to_outbox( + self, obj_id: str, activity: ObjectType, recipients: List[str] + ) -> None: # ABC self.outbox_create(self) @@ -1017,10 +1064,10 @@ class Note(BaseActivity): def _init(self, **kwargs): print(self._data) # Remove the `actor` field as `attributedTo` is used for `Note` instead - if 'actor' in self._data: - del(self._data['actor']) - if 'sensitive' not in kwargs: - self._data['sensitive'] = False + if "actor" in self._data: + del (self._data["actor"]) + if "sensitive" not in kwargs: + self._data["sensitive"] = False def _recipients(self) -> List[str]: # TODO(tsileo): audience support? @@ -1033,7 +1080,7 @@ class Note(BaseActivity): # print('publishing to public instances') # print(recipients) - for field in ['to', 'cc', 'bto', 'bcc']: + for field in ["to", "cc", "bto", "bcc"]: if field in self._data: recipients.extend(_to_list(self._data[field])) @@ -1042,10 +1089,10 @@ class Note(BaseActivity): def build_create(self) -> BaseActivity: """Wraps an activity in a Create activity.""" create_payload = { - 'object': self.to_dict(embed=True), - 'actor': self.attributedTo, + "object": self.to_dict(embed=True), + "actor": self.attributedTo, } - for field in ['published', 'to', 'bto', 'cc', 'bcc', 'audience']: + for field in ["published", "to", "bto", "cc", "bcc", "audience"]: if field in self._data: create_payload[field] = self._data[field] @@ -1058,8 +1105,11 @@ class Note(BaseActivity): return Announce( object=self.id, to=[AS_PUBLIC], - cc=[self.follower_collection_id(self.get_actor()), self.attributedTo], # ABC - published=datetime.utcnow().replace(microsecond=0).isoformat() + 'Z', + cc=[ + self.follower_collection_id(self.get_actor()), + self.attributedTo, + ], # ABC + published=datetime.utcnow().replace(microsecond=0).isoformat() + "Z", ) def build_delete(self) -> BaseActivity: @@ -1067,10 +1117,7 @@ class Note(BaseActivity): def get_tombstone(self, deleted: Optional[str]) -> BaseActivity: return Tombstone( - id=self.id, - published=self.published, - deleted=deleted, - updated=deleted, + id=self.id, published=self.published, deleted=deleted, updated=deleted ) @@ -1080,10 +1127,11 @@ class Box(object): class Outbox(Box): - def post(self, activity: BaseActivity) -> None: if activity.get_actor().id != self.actor.id: - raise ValueError(f'{activity.get_actor()!r} cannot post into {self.actor!r} outbox') + raise ValueError( + f"{activity.get_actor()!r} cannot post into {self.actor!r} outbox" + ) activity.post_to_outbox() @@ -1096,6 +1144,5 @@ class Outbox(Box): class Inbox(Box): - def post(self, activity: BaseActivity) -> None: activity.process_from_inbox(self.actor) diff --git a/little_boxes/errors.py b/little_boxes/errors.py index cb3cb34..67ac727 100644 --- a/little_boxes/errors.py +++ b/little_boxes/errors.py @@ -6,10 +6,12 @@ from typing import Any class Error(Exception): """HTTP-friendly base error, with a status code, a message and an optional payload.""" + status_code = 400 def __init__( - self, message: str, + self, + message: str, status_code: Optional[int] = None, payload: Optional[Dict[str, Any]] = None, ) -> None: @@ -21,13 +23,11 @@ class Error(Exception): def to_dict(self) -> Dict[str, Any]: rv = dict(self.payload or ()) - rv['message'] = self.message + rv["message"] = self.message return rv def __repr__(self) -> str: - return ( - f'{self.__class__.__qualname__}({self.message!r}, payload={self.payload!r}, status_code={self.status_code})' - ) + return f"{self.__class__.__qualname__}({self.message!r}, payload={self.payload!r}, status_code={self.status_code})" class ActorBlockedError(Error): @@ -40,6 +40,7 @@ class NotFromOutboxError(Error): class ActivityNotFoundError(Error): """Raised when an activity is not found.""" + status_code = 404 diff --git a/little_boxes/urlutils.py b/little_boxes/urlutils.py index 99f900d..0a72fc5 100644 --- a/little_boxes/urlutils.py +++ b/little_boxes/urlutils.py @@ -16,25 +16,25 @@ class InvalidURLError(Error): def is_url_valid(url: str) -> bool: parsed = urlparse(url) - if parsed.scheme not in ['http', 'https']: + if parsed.scheme not in ["http", "https"]: return False # XXX in debug mode, we want to allow requests to localhost to test the federation with local instances - debug_mode = strtobool(os.getenv('MICROBLOGPUB_DEBUG', 'false')) + debug_mode = strtobool(os.getenv("MICROBLOGPUB_DEBUG", "false")) if debug_mode: return True - if parsed.hostname in ['localhost']: + if parsed.hostname in ["localhost"]: return False try: ip_address = socket.getaddrinfo(parsed.hostname, parsed.port or 80)[0][4][0] except socket.gaierror: - logger.exception(f'failed to lookup url {url}') + logger.exception(f"failed to lookup url {url}") return False if ipaddress.ip_address(ip_address).is_private: - logger.info(f'rejecting private URL {url}') + logger.info(f"rejecting private URL {url}") return False return True diff --git a/little_boxes/utils.py b/little_boxes/utils.py index 2476182..be89c11 100644 --- a/little_boxes/utils.py +++ b/little_boxes/utils.py @@ -18,43 +18,45 @@ def parse_collection( ) -> List[Any]: """Resolve/fetch a `Collection`/`OrderedCollection`.""" if not fetcher: - raise Exception('must provide a fetcher') + raise Exception("must provide a fetcher") if level > 3: - raise RecursionLimitExceededError('recursion limit exceeded') + raise RecursionLimitExceededError("recursion limit exceeded") # Go through all the pages out: List[Any] = [] if url: payload = fetcher(url) if not payload: - raise ValueError('must at least prove a payload or an URL') + raise ValueError("must at least prove a payload or an URL") - if payload['type'] in ['Collection', 'OrderedCollection']: - if 'orderedItems' in payload: - return payload['orderedItems'] - if 'items' in payload: - return payload['items'] - if 'first' in payload: - if 'orderedItems' in payload['first']: - out.extend(payload['first']['orderedItems']) - if 'items' in payload['first']: - out.extend(payload['first']['items']) - n = payload['first'].get('next') + if payload["type"] in ["Collection", "OrderedCollection"]: + if "orderedItems" in payload: + return payload["orderedItems"] + if "items" in payload: + return payload["items"] + if "first" in payload: + if "orderedItems" in payload["first"]: + out.extend(payload["first"]["orderedItems"]) + if "items" in payload["first"]: + out.extend(payload["first"]["items"]) + n = payload["first"].get("next") if n: - out.extend(parse_collection(url=n, level=level+1, fetcher=fetcher)) + out.extend(parse_collection(url=n, level=level + 1, fetcher=fetcher)) return out while payload: - if payload['type'] in ['CollectionPage', 'OrderedCollectionPage']: - if 'orderedItems' in payload: - out.extend(payload['orderedItems']) - if 'items' in payload: - out.extend(payload['items']) - n = payload.get('next') + if payload["type"] in ["CollectionPage", "OrderedCollectionPage"]: + if "orderedItems" in payload: + out.extend(payload["orderedItems"]) + if "items" in payload: + out.extend(payload["items"]) + n = payload.get("next") if n is None: break payload = fetcher(n) else: - raise UnexpectedActivityTypeError('unexpected activity type {}'.format(payload['type'])) + raise UnexpectedActivityTypeError( + "unexpected activity type {}".format(payload["type"]) + ) return out diff --git a/requirements.txt b/requirements.txt index 07bd717..567801d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,3 @@ pyld pycryptodome html2text mf2py -git+https://github.com/erikriver/opengraph.git diff --git a/setup.py b/setup.py index 7d98d26..6f4db9e 100644 --- a/setup.py +++ b/setup.py @@ -1,14 +1,66 @@ #!/usr/bin/env python +import io +import os from distutils.core import setup from setuptools import find_packages -setup( - name='Little Boxes', - version='0.1.0', - description='Tiny ActivityPub framework written in Python, both database and server agnostic.', - author='Thomas Sileo', - author_email='t@a4.io', - url='https://github.com/tsileo/little-boxes', - packages=find_packages(), +here = os.path.abspath(os.path.dirname(__file__)) + + +# Package meta-data. +NAME = "little_boxes" +DESCRIPTION = ( + "Tiny ActivityPub framework written in Python, both database and server agnostic." +) +URL = "https://github.com/tsileo/little-boxes" +EMAIL = "t@a4.io" +AUTHOR = "Thomas Sileo" +REQUIRES_PYTHON = ">=3.6.0" +VERSION = None + + +REQUIRED = ["requests", "markdown", "pyld", "pycryptodome", "html2text"] + +DEPENDENCY_LINKS = [] + + +# Load the package's __version__.py module as a dictionary. +about = {} +if not VERSION: + with open(os.path.join(here, NAME, "__version__.py")) as f: + exec(f.read(), about) +else: + about["__version__"] = VERSION + + +# Import the README and use it as the long-description. +with io.open(os.path.join(here, "README.md"), encoding="utf-8") as f: + long_description = "\n" + f.read() + + +setup( + name=NAME, + version=about["__version__"], + description=DESCRIPTION, + long_description=long_description, + long_description_content_type="text/markdown", + author=AUTHOR, + author_email=EMAIL, + python_requires=REQUIRES_PYTHON, + url=URL, + packages=find_packages(), + install_requires=REQUIRED, + dependency_links=DEPENDENCY_LINKS, + license="ISC", + classifiers=[ + # Trove classifiers + # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: ISC License (ISCL)", + "Programming Language :: Python", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + ], ) diff --git a/tests/test_little_boxes.py b/tests/test_little_boxes.py index 20c823f..2ac0295 100644 --- a/tests/test_little_boxes.py +++ b/tests/test_little_boxes.py @@ -2,6 +2,7 @@ from unittest import mock from little_boxes import activitypub as ap + def _assert_eq(val, other): assert val == other @@ -10,89 +11,85 @@ def test_little_boxes_follow(): back = ap.BaseBackend() ap.use_backend(back) - me = back.setup_actor('Thomas', 'tom') + me = back.setup_actor("Thomas", "tom") - other = back.setup_actor('Thomas', 'tom2') + other = back.setup_actor("Thomas", "tom2") outbox = ap.Outbox(me) - f = ap.Follow( - actor=me.id, - object=other.id, - ) + f = ap.Follow(actor=me.id, object=other.id) outbox.post(f) back.assert_called_methods( - me, - ( - 'follow is saved in the actor inbox', - 'outbox_new', - lambda as_actor: _assert_eq(as_actor.id, me.id), - lambda activity: _assert_eq(activity.id, f.id) - ), - ( - 'follow is sent to the remote followee inbox', - 'post_to_remote_inbox', - lambda as_actor: _assert_eq(as_actor.id, me.id), - lambda payload: None, - lambda recipient: _assert_eq(recipient, other.inbox), - ), - ( - 'receiving an accept, ensure we check the actor is not blocked', - 'outbox_is_blocked', - lambda as_actor: _assert_eq(as_actor.id, me.id), - lambda remote_actor: _assert_eq(remote_actor, other.id), - ), - ( - 'receiving the accept response from the follow', - 'inbox_new', - lambda as_actor: _assert_eq(as_actor.id, me.id), - lambda activity: _assert_eq(activity.get_object().id, f.id), - ), - ( - 'the new_following hook is called', - 'new_following', - lambda as_actor: _assert_eq(as_actor.id, me.id), - lambda activity: _assert_eq(activity.id, f.id), - ), + me, + ( + "follow is saved in the actor inbox", + "outbox_new", + lambda as_actor: _assert_eq(as_actor.id, me.id), + lambda activity: _assert_eq(activity.id, f.id), + ), + ( + "follow is sent to the remote followee inbox", + "post_to_remote_inbox", + lambda as_actor: _assert_eq(as_actor.id, me.id), + lambda payload: None, + lambda recipient: _assert_eq(recipient, other.inbox), + ), + ( + "receiving an accept, ensure we check the actor is not blocked", + "outbox_is_blocked", + lambda as_actor: _assert_eq(as_actor.id, me.id), + lambda remote_actor: _assert_eq(remote_actor, other.id), + ), + ( + "receiving the accept response from the follow", + "inbox_new", + lambda as_actor: _assert_eq(as_actor.id, me.id), + lambda activity: _assert_eq(activity.get_object().id, f.id), + ), + ( + "the new_following hook is called", + "new_following", + lambda as_actor: _assert_eq(as_actor.id, me.id), + lambda activity: _assert_eq(activity.id, f.id), + ), ) back.assert_called_methods( - other, - ( - 'receiving the follow, ensure we check the actor is not blocked', - 'outbox_is_blocked', - lambda as_actor: _assert_eq(as_actor.id, other.id), - lambda remote_actor: _assert_eq(remote_actor, me.id), - ), - ( - 'receiving the follow activity', - 'inbox_new', - lambda as_actor: _assert_eq(as_actor.id, other.id), - lambda activity: _assert_eq(activity.id, f.id), - ), - ( - 'posting an accept in response to the follow', - 'outbox_new', - lambda as_actor: _assert_eq(as_actor.id, other.id), - lambda activity: _assert_eq(activity.get_object().id, f.id), - ), - ( - 'post the accept to the remote follower inbox', - 'post_to_remote_inbox', - lambda as_actor: _assert_eq(as_actor.id, other.id), - lambda payload: None, - lambda recipient: _assert_eq(recipient, me.inbox), - ), - ( - 'the new_follower hook is called', - 'new_follower', - lambda as_actor: _assert_eq(as_actor.id, other.id), - lambda activity: _assert_eq(activity.id, f.id), - ), + other, + ( + "receiving the follow, ensure we check the actor is not blocked", + "outbox_is_blocked", + lambda as_actor: _assert_eq(as_actor.id, other.id), + lambda remote_actor: _assert_eq(remote_actor, me.id), + ), + ( + "receiving the follow activity", + "inbox_new", + lambda as_actor: _assert_eq(as_actor.id, other.id), + lambda activity: _assert_eq(activity.id, f.id), + ), + ( + "posting an accept in response to the follow", + "outbox_new", + lambda as_actor: _assert_eq(as_actor.id, other.id), + lambda activity: _assert_eq(activity.get_object().id, f.id), + ), + ( + "post the accept to the remote follower inbox", + "post_to_remote_inbox", + lambda as_actor: _assert_eq(as_actor.id, other.id), + lambda payload: None, + lambda recipient: _assert_eq(recipient, me.inbox), + ), + ( + "the new_follower hook is called", + "new_follower", + lambda as_actor: _assert_eq(as_actor.id, other.id), + lambda activity: _assert_eq(activity.id, f.id), + ), ) - assert back.followers(other) == [me.id] assert back.following(other) == [] @@ -104,15 +101,12 @@ def test_little_boxes_follow_unfollow(): back = ap.BaseBackend() ap.use_backend(back) - me = back.setup_actor('Thomas', 'tom') + me = back.setup_actor("Thomas", "tom") - other = back.setup_actor('Thomas', 'tom2') + other = back.setup_actor("Thomas", "tom2") outbox = ap.Outbox(me) - f = ap.Follow( - actor=me.id, - object=other.id, - ) + f = ap.Follow(actor=me.id, object=other.id) outbox.post(f) @@ -128,4 +122,4 @@ def test_little_boxes_follow_unfollow(): # assert back.following(other) == [] # assert back.followers(me) == [] - # assert back.following(me) == [] + # assert back.following(me) == []