diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml deleted file mode 100644 index 50d45b7..0000000 --- a/.github/workflows/black.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Code style - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - lint: - runs-on: ubuntu-latest - - name: Code Style - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 - with: - python-version: '3.x' - - uses: psf/black@20.8b1 diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml new file mode 100644 index 0000000..358bd33 --- /dev/null +++ b/.github/workflows/linters.yml @@ -0,0 +1,34 @@ +name: Code quality + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + black: + name: Check code style + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.x' + - uses: psf/black@20.8b1 + + pycln: + name: Check imports + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.x' + - run: | + pip install poetry + poetry install + - run: | + poetry run pycln --check f2ap tests diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..f9a80ae --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,74 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + pytest: + name: Unit tests + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + python_version: + - '3.9' + - '3.10' + - '3.11' + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.x' + - run: | + pip install poetry + poetry install + - run: | + poetry run pytest --cov=f2ap tests/*.py + + - name: Push code coverage + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COVERALLS_PARALLEL: true + COVERALLS_FLAG_NAME: "Py${{ matrix.python_version }}_${{ matrix.os }}" + run: | + python3 -m poetry run coveralls --service=github + + # Upload generated artifacts only if tests don't pass, to help debugging. + - name: Upload artifacts + uses: actions/upload-artifact@v3 + if: failure() + with: + name: test-files + path: tests/files/ + + coverage: + name: Push coverage report + needs: pytest + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Prepare Python + uses: actions/setup-python@v4 + with: + python-version: "3.x" + + - name: Install dependencies + run: | + pip install poetry + poetry install + + - name: Upload coverage report + run: | + poetry run coveralls --finish --service=github + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 02e5802..009b4aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ *.toml !*.dist.toml *.db +*.bak + +!/tests/files/database/upgrade/database-v1.db +.coverage diff --git a/README.md b/README.md index c7eade7..68c6e30 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # ![f2ap](logo.svg) +[![Coverage Status](https://coveralls.io/repos/github/Deuchnord/f2ap/badge.svg?branch=main)](https://coveralls.io/github/Deuchnord/f2ap?branch=main) + f2ap (_Feed to ActivityPub_) is a web application that uses the RSS/Atom feed of your website to expose it on the Fediverse through ActivityPub. @@ -95,12 +97,6 @@ server { } } - # Exposes the avatar and the header of the profile - # Change the here with the username of the actor you expose (for instance: blog) - location ~ /actors//(avatar|header) { - proxy_pass http://127.0.0.1:8000; - } - ## ... } ``` diff --git a/config.dist.toml b/config.dist.toml index 50f4110..8aae43c 100644 --- a/config.dist.toml +++ b/config.dist.toml @@ -17,8 +17,8 @@ update_freq = 5 [actor] username = "blog" display_name = "The most perfect blog of the Web" -avatar = "/path/to/avatar.png" -header = "/path/to/header.jpg" +avatar = "https://example.com/images/avatar.png" +header = "https://example.com/images/header.jpg" summary = "Why make threads when you can have a blog? 👀" # A list of people you want the actor to follow, in `@username@example.com` format. @@ -60,3 +60,13 @@ format = "[{title}]({url})\n{summary}\n{tags}" # Available formats: camelCase, CamelCase, snake_case # Default: camelCase tag_format = "camelCase" + +# A list of groups you want to send the message to, additionally to the followers. +# This is required to get the messages discoverable by some social applications like Lemmy. +groups = [] + +# Set this to true to enable the comments feature. +# When disabled (default behavior): +# - the comments are silently rejected (social applications still receive an Accepted response to prevent them sending the responses again and again) +# - the API and JS widget are not available +accept_responses = false diff --git a/f2ap/activitypub.py b/f2ap/activitypub.py index 6b85c09..8d6e1a3 100644 --- a/f2ap/activitypub.py +++ b/f2ap/activitypub.py @@ -2,14 +2,16 @@ import requests import logging -from typing import Union +from dateutil import parser as dateparser +from typing import Union, Optional, Callable from uuid import uuid4 -from . import postie, model +from . import postie, model, signature, html from .config import Configuration -from .markdown import parse_markdown, find_hashtags - -W3_PUBLIC_STREAM = "https://www.w3.org/ns/activitystreams#Public" +from .data import Database +from .enum import Visibility +from .exceptions import UnauthorizedHttpError +from .markdown import parse_markdown MIME_JSON_ACTIVITY = "application/activity+json" @@ -37,7 +39,7 @@ def search_actor(domain: str, username: str) -> Union[None, dict]: return None -def get_actor(href: str): +def get_actor(href: str) -> dict: try: actor = requests.get(href, headers={"Accept": "application/activity+json"}) actor.raise_for_status() @@ -139,3 +141,133 @@ def propagate_messages( message.object.content = parse_markdown(message.object.content) for inbox in inboxes: postie.deliver(config, inbox, message.dict()) + + +def handle_inbox( + config: Configuration, + db: Database, + headers: dict, + inbox: dict, + on_following_accepted: Callable, +) -> Union[None, tuple[dict, dict]]: + actor = get_actor_from_inbox(db, inbox) + if actor is None: + return + + check_message_signature(config, actor, headers, inbox) + + return actor, handle_inbox_message(db, inbox, on_following_accepted, config.message.accept_responses) + + +def get_actor_from_inbox(db: Database, inbox: dict) -> dict: + try: + actor = requests.get(inbox.get("actor"), headers={"Accept": MIME_JSON_ACTIVITY}) + + actor.raise_for_status() + + actor = actor.json() + return actor + + except requests.exceptions.HTTPError: + # If the message says the actor has been deleted, delete it from the followers (if they were following) + if inbox.get("type") == "Delete" and inbox.get("actor") == inbox.get("object"): + db.delete_follower(inbox.get("object")) + + return None + + +def check_message_signature( + config: Configuration, actor: dict, headers: dict, inbox: dict +): + public_key_pem = actor.get("publicKey", {}).get("publicKeyPem") + + try: + if public_key_pem is None: + raise ValueError("Missing public key on actor.") + + signature.validate_headers( + public_key_pem, headers, f"/actors/{config.actor.preferred_username}/inbox" + ) + + return + + except ValueError as e: + logging.debug(f"Could not validate signature: {e.args[0]}. Request rejected.") + logging.debug(f"Headers: {headers}") + logging.debug(f"Public key: {public_key_pem}") + logging.debug(inbox) + + raise UnauthorizedHttpError(str(e)) + + +def handle_inbox_message( + db: Database, inbox: dict, on_following_accepted: Callable, accept_responses: bool +) -> Optional[dict]: + if ( + inbox.get("type") == "Accept" + and inbox.get("object", {}).get("type") == "Follow" + ): + on_following_accepted(inbox.get("object").get("id"), inbox.get("actor")) + logging.debug(f"Following {inbox.get('actor')} successful.") + return + + if inbox.get("type") == "Follow": + db.insert_follower(inbox.get("actor")) + return {"type": "Accept", "object": inbox} + + if inbox.get("type") == "Undo" and inbox.get("object", {}).get("type") == "Follow": + db.delete_follower(inbox.get("actor")) + return + + if accept_responses and inbox.get("type") == "Create" and inbox.get("object", {}).get("type") == "Note": + # Save comments to a note + note = inbox.get("object") + in_reply_to = note.get("inReplyTo") + if in_reply_to is None: + return + + replying_to = db.get_note(in_reply_to) + if replying_to is None: + return + + content = html.sanitize(note["content"]) + published_at = dateparser.isoparse(note["published"]) + db.insert_comment( + replying_to, + note["id"], + published_at, + note["attributedTo"], + content, + get_note_visibility(note), + note["tag"], + ) + + return + + if inbox.get("type") == "Delete": + o = inbox.get("object", {}) + if not isinstance(o, dict): + logging.debug(f"Tried to delete unsupported object: {inbox}") + return + + # Tombstone might be a Note, try to delete it. + # Note: this is always done, even when accept_responses is False, just in case it has been disabled lately. + db.delete_comment(o.get("id")) + + return + + logging.debug(f"Unsupported message received in the inbox: {inbox}") + + +def get_note_visibility(note: dict) -> Visibility: + author = get_actor(note.get("attributedTo")) + if author is None: + return Visibility.MENTIONED_ONLY + + if model.W3C_ACTIVITYSTREAMS_PUBLIC not in [*note.get("to"), *note.get("cc")]: + if author.get("followers") in [*note.get("to", []), *note.get("cc", [])]: + return Visibility.FOLLOWERS_ONLY + + return Visibility.MENTIONED_ONLY + + return Visibility.PUBLIC diff --git a/f2ap/config.py b/f2ap/config.py index 54b2401..5fb8966 100644 --- a/f2ap/config.py +++ b/f2ap/config.py @@ -1,7 +1,11 @@ +import logging + import toml import humps +import requests from typing import Callable, Union +from .enum import Visibility class Website: @@ -40,7 +44,9 @@ def __init__( self.display_name = display_name self.summary = summary self.avatar = avatar + self.avatar_type = None self.header = header + self.header_type = None self.following = followings if followings is not None else [] self.attachments = attachments @@ -49,6 +55,57 @@ def __init__( with open(private_key, "r") as file: self.private_key = file.read() + for what, url in [("avatar", avatar), ("header", header)]: + if url is None: + continue + + try: + ct = self.get_attachment_type(what, url) + if what == "avatar": + self.avatar_type = ct + else: + self.header_type = ct + + except requests.HTTPError as e: + logging.warning( + f"Could not load the {what} metadata at {url}: {e}." + f" It may not appear correctly on social applications." + ) + + @staticmethod + def get_attachment_type(what: str, url: str) -> str: + avatar_header_preferred_mimes = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + ] + + r = requests.head(url) + r.raise_for_status() + content_type = r.headers.get("Content-Type") + + if content_type is None: + logging.warning( + f"Could not determine the type of the {what} at {url}:" + f" server does not provide a Content-Type." + f" It may not appear on social applications." + ) + elif not content_type.startswith("image/"): + logging.warning( + f"The {what} at {url} is reported with MIME type {content_type}," + f" which does not match an image. It may not appear on social applications." + ) + elif content_type not in avatar_header_preferred_mimes: + logging.warning( + f"The {what} at {url} is reported with MIME type {content_type}," + f" which is unusual image type for the Web" + f" (usual images types are {', '.join(avatar_header_preferred_mimes)})." + f" It may not appear on social applications." + ) + + return content_type + @property def id(self) -> str: return f"https://{self.config.url}/actors/{self.preferred_username}" @@ -75,7 +132,9 @@ def followers_link(self) -> str: class Message: - def __init__(self, format: str, tag_format: str = "camelCase"): + def __init__( + self, format: str, tag_format: str = "camelCase", groups: [str] = None, accept_responses: bool = False + ): valid_tag_formats = self.get_tags_formatters().keys() self.format = format @@ -87,6 +146,8 @@ def __init__(self, format: str, tag_format: str = "camelCase"): ) self.tag_format = tag_format + self.groups = groups if groups is not None else [] + self.accept_responses = accept_responses @staticmethod def get_tags_formatters() -> {str: Callable}: @@ -100,6 +161,20 @@ def get_tags_formatter(self) -> Union[Callable, None]: return self.get_tags_formatters().get(self.tag_format) +class Comments: + def __init__( + self, + enable: bool = False, + js_widget: bool = False, + minimal_visibility: Visibility = Visibility.PUBLIC, + accept_sensitive: bool = False, + ): + self.enable = enable + self.js_widget = js_widget + self.minimal_visibility = minimal_visibility + self.accept_sensitive = accept_sensitive + + def get_config(file_path: str) -> Configuration: with open(file_path) as file: content = toml.loads(file.read()) diff --git a/f2ap/data.py b/f2ap/data.py index 4e4c477..ea0feeb 100644 --- a/f2ap/data.py +++ b/f2ap/data.py @@ -1,6 +1,7 @@ import json +import logging import sqlite3 -import markdown +import shutil from datetime import datetime, timezone from uuid import uuid4, UUID @@ -9,10 +10,49 @@ from . import model from .config import Configuration +from .enum import Visibility + +# Note: primary keys are defined as nullable because of a mistake made in the first version (I didn't even know this was possible). +# We need to alter tables later to fix that. +TABLES = { + "metadata": { + "key": {"type": "VARCHAR(50)", "primary": True, "nullable": True}, + "value": {"type": "TEXT", "primary": False, "nullable": True}, + }, + "messages": { + "uuid": {"type": "VARCHAR(36)", "primary": True, "nullable": True}, + "msg_type": {"type": "VARCHAR(20)", "primary": False, "nullable": False}, + "note": {"type": "VARCHAR(36)", "primary": False, "nullable": False}, + }, + "notes": { + "uuid": {"type": "VARCHAR(36)", "primary": True, "nullable": True}, + "published_time": {"type": "INTEGER", "primary": False, "nullable": False}, + "url": {"type": "VARCHAR(255)", "primary": False, "nullable": False}, + "name": {"type": "VARCHAR(500)", "primary": False, "nullable": True}, + "reply_to": {"type": "VARCHAR(255)", "primary": False, "nullable": True}, + "content": {"type": "TEXT", "primary": False, "nullable": False}, + "tags": {"type": "TEXT", "primary": False, "nullable": True}, + }, + "followers": { + "uuid": {"type": "VARCHAR(36)", "primary": True, "nullable": True}, + "follower_since": {"type": "INTEGER", "primary": False, "nullable": False}, + "link": {"type": "VARCHAR(255)", "primary": False, "nullable": False}, + }, + "comments": { + "uuid": {"type": "VARCHAR(36)", "primary": True, "nullable": True}, + "url": {"type": "VARCHAR(255)", "primary": False, "nullable": False}, + "attributed_to": {"type": "VARCHAR(255)", "primary": False, "nullable": True}, + "replying_to": {"type": "VARCHAR(36)", "primary": False, "nullable": False}, + "published_time": {"type": "INTEGER", "primary": False, "nullable": False}, + "content": {"type": "TEXT", "primary": False, "nullable": False}, + "visibility": {"type": "INTEGER", "primary": False, "nullable": False}, + "tags": {"type": "TEXT", "primary": False, "nullable": True}, + }, +} W3C_PUBLIC_STREAM = "https://www.w3.org/ns/activitystreams#Public" -DATABASE_VERSION = 1 +DATABASE_VERSION = 2 class Database: @@ -51,6 +91,26 @@ def set_metadata(self, key: str, value): {"key": key, "value": value}, ) + def get_schema_info(self) -> dict: + tables = {} + + for _, table_name, _type, _, _, _ in self.execute("PRAGMA main.table_list"): + if _type != "table" or table_name == "sqlite_schema": + continue + + tables[table_name] = {} + + for _, field_name, field_type, not_null, _, is_primary in self.execute( + f"PRAGMA table_info('{table_name}')" + ): + tables[table_name][field_name] = { + "type": field_type, + "nullable": not not_null, + "primary": bool(is_primary), + } + + return tables + def get_database_version(self): return int(self.get_metadata("version")) @@ -61,58 +121,124 @@ def is_database_compatible(self): return self.get_database_version() <= DATABASE_VERSION def upgrade_database(self) -> bool: - """Returns True if the database has been upgraded""" - if self.get_database_version() == DATABASE_VERSION: + """Returns True if the database has been upgraded + + Note: except for versions < 1.0 and major releases, modifying this function is forbidden, + as it means breaking backwards compatibility. + """ + current_db_version = self.get_database_version() + if current_db_version == DATABASE_VERSION: return False - # Add upgrade instructions here + logging.info("Started upgrade database") + + backup = f"{self.config.db}.{int(datetime.utcnow().timestamp())}.bak" + shutil.copyfile(self.config.db, backup) + + logging.info(f"Database backed up to {backup}") + + ########################################## + # BEGIN incremental upgrade instructions # + ########################################## + + if current_db_version == 1: + logging.debug("Upgrading from v1 to v2...") + self.init_database(update=True, only_tables=["comments"]) + self.execute( + """ + ALTER TABLE notes + ADD name VARCHAR(500) + """ + ) + + results = self.execute( + """ + SELECT uuid, url + FROM notes + """ + ).fetchall() + + # We import them here to prevent importing useless libraries outside the upgrade path + import requests + from bs4 import BeautifulSoup + + for uuid, url in results: + logging.debug(f"Updating note: {url}") + try: + r = requests.get(url) + r.raise_for_status() + soup = BeautifulSoup(r.text, features="html.parser") + + self.execute( + """ + UPDATE notes + SET name = :name + WHERE uuid = :uuid + """, + {"name": soup.title.string, "uuid": uuid}, + ) + except requests.HTTPError as e: + logging.warning( + f"Could not update note at {url}: {e}." + f" It might be unreachable on some social application." + ) + + logging.debug("Upgraded to v2!") + + current_db_version += 1 + + ######################################## + # END incremental upgrade instructions # + ######################################## + + if current_db_version != DATABASE_VERSION: + shutil.copyfile(backup, self.config.db) + + raise ValueError( + f"Database version mismatch after upgrade: expected {DATABASE_VERSION}," + f" got {current_db_version}. The database has not been upgraded." + ) + + logging.info("Upgrade finished! You can remove the backup safely.") self.set_metadata("version", DATABASE_VERSION) return True - def init_database(self): - if exists(self.file_path): + def init_database(self, update: bool = False, only_tables: [str] = None): + only_tables = [] if only_tables is None else only_tables + + if not update and exists(self.file_path): raise IOError( - f"Database already exists. If you really want to reinitialize the data, delete it or rename it first." + "Database already exists. If you really want to reinitialize the data, delete it or rename it first." ) - tables = { - "metadata": { - "key": "VARCHAR(50) PRIMARY KEY", - "value": "TEXT", - }, - "messages": { - "uuid": "VARCHAR(36) PRIMARY KEY", - "msg_type": "VARCHAR(20) NOT NULL", - "note": "VARCHAR(36) NOT NULL", - }, - "notes": { - "uuid": "VARCHAR(36) PRIMARY KEY", - "published_time": "INTEGER NOT NULL", - "url": "VARCHAR(255) NOT NULL", - "reply_to": "VARCHAR(255)", - "content": "TEXT NOT NULL", - "tags": "TEXT", - }, - "followers": { - "uuid": "VARCHAR(36) PRIMARY KEY", - "follower_since": "INTEGER NOT NULL", - "link": "VARCHAR(255) NOT NULL", - }, - } + if update and len(only_tables) == 0: + raise ValueError("Update mode requires a list of tables.") with sqlite3.connect(self.file_path) as connection: cursor = connection.cursor() - for table in tables: + for table in TABLES: + if update and table not in only_tables: + continue + sql = f"CREATE TABLE {table}(" sep = "" - for field in tables[table]: - sql += f"{sep}{field} {tables[table][field]}" + for field in TABLES[table]: + field_info = TABLES[table][field] + sql += f"{sep}{field} {field_info.get('type', 'TEXT')}" + + if field_info.get("primary", False): + sql += " PRIMARY KEY" + if not field_info.get("nullable", True): + sql += " NOT NULL" + sep = ", " sql += ")" + + logging.debug(sql) cursor.execute(sql) self.set_metadata("version", DATABASE_VERSION) @@ -120,8 +246,7 @@ def init_database(self): def get_message(self, uuid: UUID) -> Optional[model.Message]: result = self.execute( """ - SELECT m.uuid as msg_uuid, m.msg_type, - n.uuid as note_uuid, n.published_time, n.url, n.reply_to, n.content + SELECT m.uuid as msg_uuid, m.msg_type, n.url FROM messages m JOIN notes n ON n.uuid = m.note WHERE msg_uuid = :uuid @@ -132,28 +257,21 @@ def get_message(self, uuid: UUID) -> Optional[model.Message]: if result is None: return None - msg_uuid, msg_type, note_uuid, published, url, reply_to, content = result - published_at = datetime.fromtimestamp(published) + msg_uuid, msg_type, url = result + note = self.get_note(url) return model.Message( id=f"https://{self.config.url}/messages/{msg_uuid}", type=msg_type, actor=self.config.actor.id, - published=published_at, - object=model.Note( - id=f"https://{self.config.url}/notes/{note_uuid}", - published=published_at, - url=url, - attributedTo=self.config.actor.id, - inReplyTo=reply_to, - content=content, - ), + published=note.published, + object=note, ) def get_note(self, url: str) -> Optional[model.Note]: query = self.execute( f""" - SELECT uuid, published_time, url, reply_to, content, tags + SELECT uuid, published_time, name, url, reply_to, content, tags FROM notes WHERE url = :url """, @@ -163,16 +281,18 @@ def get_note(self, url: str) -> Optional[model.Note]: if query is None: return None - uuid, published, url, reply_to, content, tags = query + uuid, published, name, url, reply_to, content, tags = query return model.Note( + uuid=uuid, id=url, + name=name, in_reply_to=reply_to, published=datetime.fromtimestamp(published, tz=timezone.utc), url=url, attributedTo=self.config.actor.id, content=model.Markdown(content), - cc=[self.config.actor.followers_link], + cc=self.config.message.groups + [self.config.actor.followers_link], tag=json.loads(tags), ) @@ -181,6 +301,7 @@ def insert_note( content: str, published_on: datetime, url: str, + name: str, reply_to: str = None, tags: [str] = None, ) -> (model.Note, UUID): @@ -190,8 +311,8 @@ def insert_note( uuid = uuid4() self.execute( """ - INSERT INTO notes(uuid, content, published_time, reply_to, url, tags) - VALUES(:uuid, :content, :published_time, :reply_to, :url, :tags) + INSERT INTO notes(uuid, content, published_time, reply_to, url, name, tags) + VALUES(:uuid, :content, :published_time, :reply_to, :url, :name, :tags) """, { "uuid": str(uuid), @@ -201,6 +322,7 @@ def insert_note( ), "reply_to": reply_to, "url": url, + "name": name, "tags": json.dumps(tags), }, ) @@ -228,8 +350,7 @@ def insert_message( def get_messages(self, order: str = "DESC") -> [dict]: results = self.execute( f""" - SELECT m.uuid as m_uuid, m.msg_type, - n.uuid as n_uuid, n.content, n.published_time, n.reply_to, n.url + SELECT m.uuid as m_uuid, m.msg_type, n.url FROM messages m JOIN notes n ON m.note = n.uuid ORDER BY n.published_time {order} @@ -241,35 +362,60 @@ def get_messages(self, order: str = "DESC") -> [dict]: for ( message_uuid, message_type, - note_uuid, - note_content, - note_published_time, - reply_to, url, ) in results: - published = datetime.fromtimestamp(note_published_time, tz=timezone.utc) + note = self.get_note(url) messages.append( model.Message( id=f"https://{self.config.url}/messages/{message_uuid}", actor=self.config.actor.id, - published=published, - object=model.Note( - id=f"https://{self.config.url}/notes/{note_uuid}", - inReplyTo=reply_to, - published=published.isoformat(), - url=url, - cc=[self.config.actor.followers_link], - attributedTo=self.config.actor.id, - content=markdown.markdown( - note_content, - extensions=["markdown.extensions.nl2br", "mdx_linkify"], - ), - ), + published=note.published, + object=note, ) ) return messages + def insert_comment( + self, + replying_to: model.Note, + url: str, + published_on: datetime, + author_url: str, + content: str, + visibility: Visibility, + tags: [model.Tag] = None, + ) -> UUID: + if tags is None: + tags = [] + + uuid = uuid4() + + self.execute( + """ + INSERT INTO comments(uuid, url, published_time, attributed_to, replying_to, content, visibility, tags) + VALUES(:uuid, :url, :published_time, :attributed_to, :replying_to, :content, :visibility, :tags) + """, + { + "uuid": str(uuid), + "url": url, + "published_time": published_on.astimezone(timezone.utc).timestamp(), + "attributed_to": author_url, + "replying_to": replying_to.id, + "content": content, + "visibility": visibility.value, + "tags": json.dumps(tags), + }, + ) + + return uuid + + def get_comments(self, note: model.Note, urls_only: bool = False): + pass + + def delete_comment(self, url: str): + self.execute("DELETE FROM comments WHERE url = :url", {"url": url}) + def get_last_note_datetime(self) -> Union[None, datetime]: (result,) = self.execute( "SELECT MAX(published_time) as dt FROM notes" diff --git a/f2ap/enum.py b/f2ap/enum.py new file mode 100644 index 0000000..ad61109 --- /dev/null +++ b/f2ap/enum.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class Visibility(Enum): + PUBLIC = 1 + FOLLOWERS_ONLY = 2 + MENTIONED_ONLY = 3 + DIRECT_MESSAGE = MENTIONED_ONLY diff --git a/f2ap/exceptions.py b/f2ap/exceptions.py new file mode 100644 index 0000000..cd6bf4e --- /dev/null +++ b/f2ap/exceptions.py @@ -0,0 +1,12 @@ +from typing import Optional, Union + + +class HttpError(IOError): + def __init__(self, status_code: int, body: Optional[Union[str, dict]]): + self.status_code = status_code + self.body = body + + +class UnauthorizedHttpError(HttpError): + def __init__(self, body: Optional[Union[str, dict]]): + super().__init__(401, body) diff --git a/f2ap/feed.py b/f2ap/feed.py index 2145861..d355b92 100644 --- a/f2ap/feed.py +++ b/f2ap/feed.py @@ -44,6 +44,9 @@ def get_inboxes(self): yield actor["inbox"] + for group in self.config.message.groups: + yield group + def update(self): logging.info("Update started") @@ -96,7 +99,7 @@ def update(self): ) note, note_uuid = self.db.insert_note( - message, published, item.link, tags=tags + message, published, item.link, item.title, tags=tags ) logging.debug("Note saved: %s" % note_uuid) message = self.db.insert_message(note_uuid) diff --git a/f2ap/html.py b/f2ap/html.py new file mode 100644 index 0000000..46ab23a --- /dev/null +++ b/f2ap/html.py @@ -0,0 +1,24 @@ +from bs4 import BeautifulSoup, Comment + +DEFAULT_AUTHORIZED_TAGS = ["a", "p", "br"] + + +# Inspired from https://gist.github.com/braveulysses/120193 +def sanitize(document: str, keep: [str] = None) -> str: + if keep is None: + keep = DEFAULT_AUTHORIZED_TAGS + + soup = BeautifulSoup(document, features="html.parser") + + # Remove HTML comments + for comment in soup.find_all(string=lambda t: isinstance(t, Comment)): + comment.extract() + + # Remove unauthorized tags + for tag in soup.find_all(): + if tag.name.lower() in keep: + continue + + tag.unwrap() + + return str(soup) diff --git a/f2ap/json.py b/f2ap/json.py index 922b405..4c6588a 100644 --- a/f2ap/json.py +++ b/f2ap/json.py @@ -2,6 +2,7 @@ from datetime import datetime from typing import Any +from uuid import UUID class ActivityJsonEncoder(json.JSONEncoder): @@ -9,4 +10,7 @@ def default(self, o: Any) -> Any: if isinstance(o, datetime): return o.isoformat() + if isinstance(o, UUID): + return str(o) + return o diff --git a/f2ap/markdown.py b/f2ap/markdown.py index da2ec67..024d529 100644 --- a/f2ap/markdown.py +++ b/f2ap/markdown.py @@ -4,14 +4,18 @@ from markdown.preprocessors import Preprocessor from markdown.extensions import Extension -EXT_NL2BR = "markdown.extensions.nl2br" +EXT_NL2BR = "nl2br" EXT_LINKIFY = "mdx_linkify" def find_hashtags(s: str) -> [str]: pattern = re.compile("#([^0-9-][^. -]*)") + tags = [] + for tag in pattern.findall(s): - yield tag + tags.append(tag) + + return tags class FediverseTagsParser(Preprocessor): diff --git a/f2ap/model.py b/f2ap/model.py index bb4d4a7..2eb5fd3 100644 --- a/f2ap/model.py +++ b/f2ap/model.py @@ -1,5 +1,5 @@ -import mimetypes from typing import Optional +from uuid import UUID from pydantic import BaseModel from datetime import datetime @@ -46,7 +46,13 @@ def __str__(self) -> str: ) -def activitystream(*additional_contexts: str): +def activitystream(contexts: list[str] = None, hide_properties: list[str] = None): + if contexts is None: + contexts = [] + + if hide_properties is None: + hide_properties = [] + def decorator(cls: type): if not issubclass(cls, BaseModel): raise TypeError( @@ -67,10 +73,8 @@ def new_dict( exclude_defaults=False, exclude_none=False, ): - d = { - "@context": ["https://www.w3.org/ns/activitystreams"] - + list(additional_contexts) - } + d = {"@context": ["https://www.w3.org/ns/activitystreams"] + contexts} + for key, value in self.old_dict( include=include, exclude=exclude, @@ -80,6 +84,9 @@ def new_dict( exclude_defaults=exclude_defaults, exclude_none=exclude_none, ).items(): + if key in hide_properties: + continue + d[key] = value return d @@ -90,38 +97,34 @@ def new_dict( return decorator -class File(BaseModel): +class Attachment(BaseModel): type: str - mediaType: str - url: str -class ImageFile(File): +class PropertyValue(Attachment): + name: str + value: str + @classmethod - def from_file(cls, path: str, url: str): - file_type, _ = mimetypes.guess_type(path, strict=True) - if file_type.split("/")[0] != "image": - raise TypeError( - f'Invalid file type for file "{path}". Check it is a valid image.' - ) + def make(cls, name: str, value: str): + return cls(type="PropertyValue", name=name, value=value) - return cls(type="Image", mediaType=file_type, url=url) +class Link(Attachment): + href: str -class PropertyValue(BaseModel): - type: str = "PropertyValue" - name: str - value: Markdown + @classmethod + def make(cls, href: str): + return cls(type="Link", href=href) -class Attachment(BaseModel): - type: str - name: str - value: str +class File(Attachment): + mediaType: Optional[str] + url: str @classmethod - def property_value(cls, name: str, value: str): - return cls(type="PropertyValue", name=name, value=value) + def make(cls, url: str, mime_type: str): + return cls(type="Image", mediaType=mime_type, url=url) class PublicKey(BaseModel): @@ -130,7 +133,7 @@ class PublicKey(BaseModel): publicKeyPem: str -@activitystream("https://w3id.org/security/v1") +@activitystream(contexts=["https://w3id.org/security/v1"]) class Actor(BaseModel): id: str url: str @@ -151,7 +154,7 @@ class Actor(BaseModel): def make_attachments(cls, attachments: {str: str}) -> list[Attachment]: l = [] for key, value in attachments.items(): - l.append(Attachment.property_value(key, Markdown(value))) + l.append(PropertyValue.make(key, Markdown(value))) return l @@ -163,8 +166,8 @@ def make(cls, actor: ConfigActor): preferredUsername=actor.preferred_username, name=actor.display_name, summary=Markdown(actor.summary), - icon=ImageFile.from_file(actor.avatar, f"{actor.id}/avatar"), - image=ImageFile.from_file(actor.header, f"{actor.id}/header"), + icon=File.make(actor.avatar, f"{actor.id}/avatar"), + image=File.make(actor.header, f"{actor.id}/header"), attachment=cls.make_attachments(actor.attachments), following=f"{actor.id}/following", followers=f"{actor.id}/followers", @@ -178,10 +181,13 @@ def make(cls, actor: ConfigActor): ) -@activitystream() +@activitystream(hide_properties=["uuid"]) class Note(BaseModel): + uuid: UUID id: str + name: Optional[str] type: str = "Note" + mediaType: str = "text/html" inReplyTo: Optional[str] published: datetime url: str @@ -189,7 +195,7 @@ class Note(BaseModel): to: list[str] = [W3C_ACTIVITYSTREAMS_PUBLIC] cc: list[str] = [] content: Markdown - attachment: list[File] = [] + attachment: list[Attachment] = [] tag: list[str] = [] diff --git a/f2ap/webserver.py b/f2ap/webserver.py index 5685ae7..926d8e6 100644 --- a/f2ap/webserver.py +++ b/f2ap/webserver.py @@ -2,8 +2,6 @@ import threading import uvicorn -import requests -import mimetypes import json import base64 import hashlib @@ -12,17 +10,16 @@ from typing import Union, Any, Optional from fastapi import FastAPI, BackgroundTasks from fastapi import Request -from fastapi.responses import Response, JSONResponse, RedirectResponse +from fastapi.responses import Response, JSONResponse from pydantic import BaseModel -from . import postie, signature, activitypub +from . import postie, activitypub from .config import Configuration from .data import Database +from .exceptions import HttpError from .model import OrderedCollection, Actor from .json import ActivityJsonEncoder -ACTIVITY_JSON_MIME_TYPE = "application/activity+json" - W3C_ACTIVITY_STREAM = "https://www.w3.org/ns/activitystreams" @@ -92,16 +89,14 @@ def f(coroutine): return decorator -def start_server( - config: Configuration, port: int, log_level: str, skip_following: bool = False -): +def get_server(config: Configuration, skip_following: bool = False): app = FastAPI(docs_url=None) app.activitypub = get_activitypub_decorator(app) db = Database(config) - start_server.following = None + get_server.following = None if skip_following: - start_server.following = [] + get_server.following = [] logging.debug("Following is disabled.") @app.middleware("http") @@ -111,8 +106,8 @@ async def on_request(request: Request, call_next): ) # If the server has just started, follow the users specified in the configuration. - if not skip_following and start_server.following is None: - start_server.following = [] + if not skip_following and get_server.following is None: + get_server.following = [] follow_task = FollowThread(config, config.actor.following) follow_task.start() @@ -131,23 +126,11 @@ async def on_request(request: Request, call_next): @app.on_event("shutdown") async def on_stop(): - if start_server.following is not None: - activitypub.unfollow_users(config, start_server.following) - - @app.get("/robots.txt") - async def robots() -> Response: - return Response( - headers={"Content-Type": "text/plain"}, - content="\n".join( - [ - "User-agent: *", - "Allow: /", - ] - ), - ) + if get_server.following is not None: + activitypub.unfollow_users(config, get_server.following) @app.get("/.well-known/webfinger") - async def webfinger(resource: Union[str, None]) -> Response: + async def webfinger(resource: Union[str, None]): subject = f"acct:{config.actor.preferred_username}@{config.url}" if resource is None or resource != subject: return Response(status_code=404) @@ -173,45 +156,17 @@ async def get_actor(username: str): return respond(Actor.make(config.actor)) - @app.head("/actors/{username}/avatar") - @app.get("/actors/{username}/avatar") - async def get_actor_avatar(username: str) -> Response: - if username != config.actor.preferred_username or config.actor.avatar is None: - return Response(status_code=404) - - file_type, _ = mimetypes.guess_type(config.actor.avatar) - - if file_type not in ["image/jpeg", "image/png"]: - return Response(status_code=404) - - with open(config.actor.avatar, "rb") as file: - return Response(file.read(), headers={"Content-Type": file_type}) - - @app.head("/actors/{username}/header") - @app.get("/actors/{username}/header") - async def get_actor_header(username: str) -> Response: - if username != config.actor.preferred_username or config.actor.header is None: - return Response(status_code=404) - - file_type, _ = mimetypes.guess_type(config.actor.header) - - if file_type not in ["image/jpeg", "image/png"]: - return Response(status_code=404) - - with open(config.actor.header, "rb") as file: - return Response(file.read(), headers={"Content-Type": file_type}) - @app.activitypub( "/actors/{username}/following", ignore_unset=True, responds_with=OrderedCollection, ) - async def get_following(username, page: Optional[int] = 0) -> Response: + async def get_following(username, page: Optional[int] = 0): if username != config.actor.preferred_username: return Response(status_code=404) following = [] - for _, account in start_server.following: + for _, account in get_server.following: following.append(account) return respond( @@ -265,69 +220,37 @@ async def post_inbox( inbox = await request.json() try: - actor = requests.get( - inbox.get("actor"), headers={"Accept": ACTIVITY_JSON_MIME_TYPE} + response = activitypub.handle_inbox( + config, + db, + dict(request.headers), + inbox, + lambda i, a: get_server.following.append((i, a)), ) - actor.raise_for_status() - actor = actor.json() - actor_inbox = actor.get("inbox") - except requests.exceptions.HTTPError: - if inbox.get("type") == "Delete" and inbox.get("actor") == inbox.get( - "object" - ): - db.delete_follower(inbox.get("object")) + except HttpError as e: + return Response(e.body, status_code=e.status_code) + if response is None: return - try: - public_key_pem = actor.get("publicKey", {}).get("publicKeyPem") - if public_key_pem is None: - raise ValueError("Missing public key on actor.") - signature.validate_headers( - public_key_pem, dict(request.headers), f"/actors/{username}/inbox" - ) - except ValueError as e: - logging.debug( - f"Could not validate signature: {e.args[0]}. Request rejected." - ) - logging.debug(f"Headers: {request.headers}") - logging.debug(f"Public key: {public_key_pem}") - logging.debug(inbox) - return Response(str(e), status_code=401) - - activity_response = None - - if inbox.get("type") == "Follow": - db.insert_follower(inbox.get("actor")) - activity_response = {"type": "Accept", "object": inbox} - elif ( - inbox.get("type") == "Accept" - and inbox.get("object", {}).get("type") == "Follow" - ): - start_server.following.append( - (inbox.get("object").get("id"), inbox.get("actor")) - ) - logging.debug(f"Following {inbox.get('actor')} successful.") - elif ( - inbox.get("type") == "Undo" - and inbox.get("object", {}).get("type") == "Follow" - ): - db.delete_follower(inbox.get("actor")) + actor, activity_response = response if activity_response is not None: activity_response["@context"] = W3C_ACTIVITY_STREAM - background_tasks.add_task( - postie.deliver, config, actor_inbox, activity_response - ) - - return + background_tasks.add_task(postie.deliver, config, actor, activity_response) @app.activitypub("/messages/{uuid}") async def get_messages(uuid: UUID): return respond(db.get_message(uuid)) + return app + + +def start_server( + config: Configuration, port: int, log_level: str, skip_following: bool = False +): uvicorn.run( - app, + get_server(config, skip_following), host="0.0.0.0", port=port, log_level=log_level.lower(), diff --git a/poetry.lock b/poetry.lock index 681863f..72404ba 100644 --- a/poetry.lock +++ b/poetry.lock @@ -15,6 +15,36 @@ doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] test = ["contextlib2", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "uvloop (>=0.15)"] trio = ["trio (>=0.16,<0.22)"] +[[package]] +name = "attrs" +version = "22.2.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"] +dev = ["attrs[docs,tests]"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"] +tests = ["attrs[tests-no-zope]", "zope.interface"] +tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"] + +[[package]] +name = "beautifulsoup4" +version = "4.11.1" +description = "Screen-scraping library" +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.dependencies] +soupsieve = ">1.2" + +[package.extras] +html5lib = ["html5lib"] +lxml = ["lxml"] + [[package]] name = "black" version = "22.12.0" @@ -91,9 +121,58 @@ category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +[[package]] +name = "coverage" +version = "6.5.0" +description = "Code coverage measurement for Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "coveralls" +version = "3.3.1" +description = "Show coverage stats online via coveralls.io" +category = "dev" +optional = false +python-versions = ">= 3.5" + +[package.dependencies] +coverage = ">=4.1,<6.0.0 || >6.1,<6.1.1 || >6.1.1,<7.0" +docopt = ">=0.6.1" +requests = ">=1.0.0" + +[package.extras] +yaml = ["PyYAML (>=3.10)"] + +[[package]] +name = "docopt" +version = "0.6.2" +description = "Pythonic argument parser, that will make you smile" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "exceptiongroup" +version = "1.1.0" +description = "Backport of PEP 654 (exception groups)" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +test = ["pytest (>=6)"] + [[package]] name = "fastapi" -version = "0.88.0" +version = "0.89.1" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" category = "main" optional = false @@ -105,9 +184,9 @@ starlette = "0.22.0" [package.extras] all = ["email-validator (>=1.1.1)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "python-multipart (>=0.0.5)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] -dev = ["pre-commit (>=2.17.0,<3.0.0)", "ruff (==0.0.138)", "uvicorn[standard] (>=0.12.0,<0.19.0)"] -doc = ["mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "typer[all] (>=0.6.1,<0.7.0)"] -test = ["anyio[trio] (>=3.2.1,<4.0.0)", "black (==22.10.0)", "coverage[toml] (>=6.5.0,<7.0)", "databases[sqlite] (>=0.3.2,<0.7.0)", "email-validator (>=1.1.1,<2.0.0)", "flask (>=1.1.2,<3.0.0)", "httpx (>=0.23.0,<0.24.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.982)", "orjson (>=3.2.1,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "peewee (>=3.13.3,<4.0.0)", "pytest (>=7.1.3,<8.0.0)", "python-jose[cryptography] (>=3.3.0,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "pyyaml (>=5.3.1,<7.0.0)", "ruff (==0.0.138)", "sqlalchemy (>=1.3.18,<=1.4.41)", "types-orjson (==3.6.2)", "types-ujson (==5.5.0)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)"] +dev = ["pre-commit (>=2.17.0,<3.0.0)", "ruff (==0.0.138)", "uvicorn[standard] (>=0.12.0,<0.21.0)"] +doc = ["mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "typer[all] (>=0.6.1,<0.8.0)"] +test = ["anyio[trio] (>=3.2.1,<4.0.0)", "black (==22.10.0)", "coverage[toml] (>=6.5.0,<8.0)", "databases[sqlite] (>=0.3.2,<0.7.0)", "email-validator (>=1.1.1,<2.0.0)", "flask (>=1.1.2,<3.0.0)", "httpx (>=0.23.0,<0.24.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.982)", "orjson (>=3.2.1,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "peewee (>=3.13.3,<4.0.0)", "pytest (>=7.1.3,<8.0.0)", "python-jose[cryptography] (>=3.3.0,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "pyyaml (>=5.3.1,<7.0.0)", "ruff (==0.0.138)", "sqlalchemy (>=1.3.18,<1.4.43)", "types-orjson (==3.6.2)", "types-ujson (==5.6.0.0)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)"] [[package]] name = "feedparser" @@ -128,6 +207,24 @@ category = "main" optional = false python-versions = ">=3.7" +[[package]] +name = "httpcore" +version = "0.16.3" +description = "A minimal low-level HTTP client." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +anyio = ">=3.0,<5.0" +certifi = "*" +h11 = ">=0.13,<0.15" +sniffio = ">=1.0.0,<2.0.0" + +[package.extras] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (>=1.0.0,<2.0.0)"] + [[package]] name = "httptools" version = "0.5.0" @@ -139,6 +236,26 @@ python-versions = ">=3.5.0" [package.extras] test = ["Cython (>=0.29.24,<0.30.0)"] +[[package]] +name = "httpx" +version = "0.23.3" +description = "The next generation HTTP client." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +certifi = "*" +httpcore = ">=0.15.0,<0.17.0" +rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]} +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<13)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (>=1.0.0,<2.0.0)"] + [[package]] name = "idna" version = "3.4" @@ -163,6 +280,30 @@ docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker perf = ["ipython"] testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "libcst" +version = "0.4.9" +description = "A concrete syntax tree with AST-like properties for Python 3.5, 3.6, 3.7, 3.8, 3.9, and 3.10 programs." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +pyyaml = ">=5.2" +typing-extensions = ">=3.7.4.2" +typing-inspect = ">=0.4.0" + +[package.extras] +dev = ["Sphinx (>=5.1.1)", "black (==22.10.0)", "coverage (>=4.5.4)", "fixit (==0.1.1)", "flake8 (>=3.7.8,<5)", "hypothesis (>=4.36.0)", "hypothesmith (>=0.0.4)", "jinja2 (==3.1.2)", "jupyter (>=1.0.0)", "maturin (>=0.8.3,<0.14)", "nbsphinx (>=0.4.2)", "prompt-toolkit (>=2.0.9)", "pyre-check (==0.9.9)", "setuptools-rust (>=1.5.2)", "setuptools-scm (>=6.0.1)", "slotscheck (>=0.7.1)", "sphinx-rtd-theme (>=0.4.3)", "ufmt (==2.0.1)", "usort (==1.0.5)"] + [[package]] name = "markdown" version = "3.4.1" @@ -197,13 +338,21 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "packaging" +version = "23.0" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" + [[package]] name = "pathspec" -version = "0.10.3" +version = "0.9.0" description = "Utility library for gitignore style pattern matching of file paths." category = "dev" optional = false -python-versions = ">=3.7" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [[package]] name = "platformdirs" @@ -217,6 +366,33 @@ python-versions = ">=3.7" docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"] test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pycln" +version = "2.1.3" +description = "A formatter for finding and removing unused import statements." +category = "dev" +optional = false +python-versions = ">=3.6.2,<4" + +[package.dependencies] +libcst = {version = ">=0.3.10,<0.5.0", markers = "python_version >= \"3.7\""} +pathspec = ">=0.9.0,<0.11.0" +pyyaml = ">=5.3.1,<7.0.0" +tomlkit = ">=0.11.1,<0.12.0" +typer = ">=0.4.1,<0.8.0" + [[package]] name = "pycryptodome" version = "3.16.0" @@ -248,6 +424,66 @@ category = "main" optional = false python-versions = "*" +[[package]] +name = "pytest" +version = "7.2.1" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "4.0.0" +description = "Pytest plugin for measuring coverage." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] + +[[package]] +name = "pytest-mock" +version = "3.10.0" +description = "Thin-wrapper around the mock package for easier use with pytest" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +pytest = ">=5.0" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + +[package.dependencies] +six = ">=1.5" + [[package]] name = "python-dotenv" version = "0.21.0" @@ -269,7 +505,7 @@ python-versions = ">=3.6" [[package]] name = "requests" -version = "2.28.1" +version = "2.28.2" description = "Python HTTP for Humans." category = "main" optional = false @@ -277,7 +513,7 @@ python-versions = ">=3.7, <4" [package.dependencies] certifi = ">=2017.4.17" -charset-normalizer = ">=2,<3" +charset-normalizer = ">=2,<4" idna = ">=2.5,<4" urllib3 = ">=1.21.1,<1.27" @@ -285,6 +521,20 @@ urllib3 = ">=1.21.1,<1.27" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "rfc3986" +version = "1.5.0" +description = "Validating URI References per RFC 3986" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +idna = {version = "*", optional = true, markers = "extra == \"idna2008\""} + +[package.extras] +idna2008 = ["idna"] + [[package]] name = "sgmllib3k" version = "1.0.0" @@ -309,6 +559,14 @@ category = "main" optional = false python-versions = ">=3.7" +[[package]] +name = "soupsieve" +version = "2.3.2.post1" +description = "A modern CSS selector implementation for Beautiful Soup." +category = "main" +optional = false +python-versions = ">=3.6" + [[package]] name = "starlette" version = "0.22.0" @@ -340,6 +598,31 @@ category = "dev" optional = false python-versions = ">=3.7" +[[package]] +name = "tomlkit" +version = "0.11.6" +description = "Style preserving TOML library" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "typer" +version = "0.7.0" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +click = ">=7.1.1,<9.0.0" + +[package.extras] +all = ["colorama (>=0.4.3,<0.5.0)", "rich (>=10.11.0,<13.0.0)", "shellingham (>=1.3.0,<2.0.0)"] +dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "pre-commit (>=2.17.0,<3.0.0)"] +doc = ["cairosvg (>=2.5.2,<3.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pillow (>=9.3.0,<10.0.0)"] +test = ["black (>=22.3.0,<23.0.0)", "coverage (>=6.2,<7.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.910)", "pytest (>=4.4.0,<8.0.0)", "pytest-cov (>=2.10.0,<5.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=1.32.0,<4.0.0)", "rich (>=10.11.0,<13.0.0)", "shellingham (>=1.3.0,<2.0.0)"] + [[package]] name = "typing-extensions" version = "4.4.0" @@ -348,6 +631,18 @@ category = "main" optional = false python-versions = ">=3.7" +[[package]] +name = "typing-inspect" +version = "0.8.0" +description = "Runtime inspection utilities for typing module." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +mypy-extensions = ">=0.3.0" +typing-extensions = ">=3.7.4" + [[package]] name = "urllib3" version = "1.26.13" @@ -438,13 +733,21 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools" [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "bdfe9c767753978755a6afbfe86047d5186f9e35ba5c170d4e1f1ddce0d21f88" +content-hash = "6b5eb8b44029f09e289020473522a6d69d3b589592749da45b4d0833a2689e25" [metadata.files] anyio = [ {file = "anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"}, {file = "anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"}, ] +attrs = [ + {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"}, + {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"}, +] +beautifulsoup4 = [ + {file = "beautifulsoup4-4.11.1-py3-none-any.whl", hash = "sha256:58d5c3d29f5a36ffeb94f02f0d786cd53014cf9b3b3951d42e0080d8a9498d30"}, + {file = "beautifulsoup4-4.11.1.tar.gz", hash = "sha256:ad9aa55b65ef2808eb405f46cf74df7fcb7044d5cbc26487f96eb2ef2e436693"}, +] black = [ {file = "black-22.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9eedd20838bd5d75b80c9f5487dbcb06836a43833a37846cf1d8c1cc01cef59d"}, {file = "black-22.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:159a46a4947f73387b4d83e87ea006dbb2337eab6c879620a3ba52699b1f4351"}, @@ -479,9 +782,72 @@ colorama = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +coverage = [ + {file = "coverage-6.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef8674b0ee8cc11e2d574e3e2998aea5df5ab242e012286824ea3c6970580e53"}, + {file = "coverage-6.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:784f53ebc9f3fd0e2a3f6a78b2be1bd1f5575d7863e10c6e12504f240fd06660"}, + {file = "coverage-6.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4a5be1748d538a710f87542f22c2cad22f80545a847ad91ce45e77417293eb4"}, + {file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83516205e254a0cb77d2d7bb3632ee019d93d9f4005de31dca0a8c3667d5bc04"}, + {file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af4fffaffc4067232253715065e30c5a7ec6faac36f8fc8d6f64263b15f74db0"}, + {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:97117225cdd992a9c2a5515db1f66b59db634f59d0679ca1fa3fe8da32749cae"}, + {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a1170fa54185845505fbfa672f1c1ab175446c887cce8212c44149581cf2d466"}, + {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:11b990d520ea75e7ee8dcab5bc908072aaada194a794db9f6d7d5cfd19661e5a"}, + {file = "coverage-6.5.0-cp310-cp310-win32.whl", hash = "sha256:5dbec3b9095749390c09ab7c89d314727f18800060d8d24e87f01fb9cfb40b32"}, + {file = "coverage-6.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:59f53f1dc5b656cafb1badd0feb428c1e7bc19b867479ff72f7a9dd9b479f10e"}, + {file = "coverage-6.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4a5375e28c5191ac38cca59b38edd33ef4cc914732c916f2929029b4bfb50795"}, + {file = "coverage-6.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4ed2820d919351f4167e52425e096af41bfabacb1857186c1ea32ff9983ed75"}, + {file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33a7da4376d5977fbf0a8ed91c4dffaaa8dbf0ddbf4c8eea500a2486d8bc4d7b"}, + {file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8fb6cf131ac4070c9c5a3e21de0f7dc5a0fbe8bc77c9456ced896c12fcdad91"}, + {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a6b7d95969b8845250586f269e81e5dfdd8ff828ddeb8567a4a2eaa7313460c4"}, + {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1ef221513e6f68b69ee9e159506d583d31aa3567e0ae84eaad9d6ec1107dddaa"}, + {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cca4435eebea7962a52bdb216dec27215d0df64cf27fc1dd538415f5d2b9da6b"}, + {file = "coverage-6.5.0-cp311-cp311-win32.whl", hash = "sha256:98e8a10b7a314f454d9eff4216a9a94d143a7ee65018dd12442e898ee2310578"}, + {file = "coverage-6.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:bc8ef5e043a2af066fa8cbfc6e708d58017024dc4345a1f9757b329a249f041b"}, + {file = "coverage-6.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4433b90fae13f86fafff0b326453dd42fc9a639a0d9e4eec4d366436d1a41b6d"}, + {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4f05d88d9a80ad3cac6244d36dd89a3c00abc16371769f1340101d3cb899fc3"}, + {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94e2565443291bd778421856bc975d351738963071e9b8839ca1fc08b42d4bef"}, + {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:027018943386e7b942fa832372ebc120155fd970837489896099f5cfa2890f79"}, + {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:255758a1e3b61db372ec2736c8e2a1fdfaf563977eedbdf131de003ca5779b7d"}, + {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:851cf4ff24062c6aec510a454b2584f6e998cada52d4cb58c5e233d07172e50c"}, + {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:12adf310e4aafddc58afdb04d686795f33f4d7a6fa67a7a9d4ce7d6ae24d949f"}, + {file = "coverage-6.5.0-cp37-cp37m-win32.whl", hash = "sha256:b5604380f3415ba69de87a289a2b56687faa4fe04dbee0754bfcae433489316b"}, + {file = "coverage-6.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4a8dbc1f0fbb2ae3de73eb0bdbb914180c7abfbf258e90b311dcd4f585d44bd2"}, + {file = "coverage-6.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d900bb429fdfd7f511f868cedd03a6bbb142f3f9118c09b99ef8dc9bf9643c3c"}, + {file = "coverage-6.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2198ea6fc548de52adc826f62cb18554caedfb1d26548c1b7c88d8f7faa8f6ba"}, + {file = "coverage-6.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c4459b3de97b75e3bd6b7d4b7f0db13f17f504f3d13e2a7c623786289dd670e"}, + {file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:20c8ac5386253717e5ccc827caad43ed66fea0efe255727b1053a8154d952398"}, + {file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b07130585d54fe8dff3d97b93b0e20290de974dc8177c320aeaf23459219c0b"}, + {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dbdb91cd8c048c2b09eb17713b0c12a54fbd587d79adcebad543bc0cd9a3410b"}, + {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:de3001a203182842a4630e7b8d1a2c7c07ec1b45d3084a83d5d227a3806f530f"}, + {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e07f4a4a9b41583d6eabec04f8b68076ab3cd44c20bd29332c6572dda36f372e"}, + {file = "coverage-6.5.0-cp38-cp38-win32.whl", hash = "sha256:6d4817234349a80dbf03640cec6109cd90cba068330703fa65ddf56b60223a6d"}, + {file = "coverage-6.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:7ccf362abd726b0410bf8911c31fbf97f09f8f1061f8c1cf03dfc4b6372848f6"}, + {file = "coverage-6.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:633713d70ad6bfc49b34ead4060531658dc6dfc9b3eb7d8a716d5873377ab745"}, + {file = "coverage-6.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:95203854f974e07af96358c0b261f1048d8e1083f2de9b1c565e1be4a3a48cfc"}, + {file = "coverage-6.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9023e237f4c02ff739581ef35969c3739445fb059b060ca51771e69101efffe"}, + {file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:265de0fa6778d07de30bcf4d9dc471c3dc4314a23a3c6603d356a3c9abc2dfcf"}, + {file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f830ed581b45b82451a40faabb89c84e1a998124ee4212d440e9c6cf70083e5"}, + {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7b6be138d61e458e18d8e6ddcddd36dd96215edfe5f1168de0b1b32635839b62"}, + {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:42eafe6778551cf006a7c43153af1211c3aaab658d4d66fa5fcc021613d02518"}, + {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:723e8130d4ecc8f56e9a611e73b31219595baa3bb252d539206f7bbbab6ffc1f"}, + {file = "coverage-6.5.0-cp39-cp39-win32.whl", hash = "sha256:d9ecf0829c6a62b9b573c7bb6d4dcd6ba8b6f80be9ba4fc7ed50bf4ac9aecd72"}, + {file = "coverage-6.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc2af30ed0d5ae0b1abdb4ebdce598eafd5b35397d4d75deb341a614d333d987"}, + {file = "coverage-6.5.0-pp36.pp37.pp38-none-any.whl", hash = "sha256:1431986dac3923c5945271f169f59c45b8802a114c8f548d611f2015133df77a"}, + {file = "coverage-6.5.0.tar.gz", hash = "sha256:f642e90754ee3e06b0e7e51bce3379590e76b7f76b708e1a71ff043f87025c84"}, +] +coveralls = [ + {file = "coveralls-3.3.1-py2.py3-none-any.whl", hash = "sha256:f42015f31d386b351d4226389b387ae173207058832fbf5c8ec4b40e27b16026"}, + {file = "coveralls-3.3.1.tar.gz", hash = "sha256:b32a8bb5d2df585207c119d6c01567b81fba690c9c10a753bfe27a335bfc43ea"}, +] +docopt = [ + {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, +] +exceptiongroup = [ + {file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"}, + {file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"}, +] fastapi = [ - {file = "fastapi-0.88.0-py3-none-any.whl", hash = "sha256:263b718bb384422fe3d042ffc9a0c8dece5e034ab6586ff034f6b4b1667c3eee"}, - {file = "fastapi-0.88.0.tar.gz", hash = "sha256:915bf304180a0e7c5605ec81097b7d4cd8826ff87a02bb198e336fb9f3b5ff02"}, + {file = "fastapi-0.89.1-py3-none-any.whl", hash = "sha256:f9773ea22290635b2f48b4275b2bf69a8fa721fda2e38228bed47139839dc877"}, + {file = "fastapi-0.89.1.tar.gz", hash = "sha256:15d9271ee52b572a015ca2ae5c72e1ce4241dd8532a534ad4f7ec70c376a580f"}, ] feedparser = [ {file = "feedparser-6.0.10-py3-none-any.whl", hash = "sha256:79c257d526d13b944e965f6095700587f27388e50ea16fd245babe4dfae7024f"}, @@ -491,6 +857,10 @@ h11 = [ {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, ] +httpcore = [ + {file = "httpcore-0.16.3-py3-none-any.whl", hash = "sha256:da1fb708784a938aa084bde4feb8317056c55037247c787bd7e19eb2c2949dc0"}, + {file = "httpcore-0.16.3.tar.gz", hash = "sha256:c5d6f04e2fc530f39e0c077e6a30caa53f1451096120f1f38b954afd0b17c0cb"}, +] httptools = [ {file = "httptools-0.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8f470c79061599a126d74385623ff4744c4e0f4a0997a353a44923c0b561ee51"}, {file = "httptools-0.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e90491a4d77d0cb82e0e7a9cb35d86284c677402e4ce7ba6b448ccc7325c5421"}, @@ -534,6 +904,10 @@ httptools = [ {file = "httptools-0.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:1af91b3650ce518d226466f30bbba5b6376dbd3ddb1b2be8b0658c6799dd450b"}, {file = "httptools-0.5.0.tar.gz", hash = "sha256:295874861c173f9101960bba332429bb77ed4dcd8cdf5cee9922eb00e4f6bc09"}, ] +httpx = [ + {file = "httpx-0.23.3-py3-none-any.whl", hash = "sha256:a211fcce9b1254ea24f0cd6af9869b3d29aba40154e947d2a07bb499b3e310d6"}, + {file = "httpx-0.23.3.tar.gz", hash = "sha256:9818458eb565bb54898ccb9b8b251a28785dd4a55afbc23d0eb410754fe7d0f9"}, +] idna = [ {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, @@ -542,6 +916,42 @@ importlib-metadata = [ {file = "importlib_metadata-6.0.0-py3-none-any.whl", hash = "sha256:7efb448ec9a5e313a57655d35aa54cd3e01b7e1fbcf72dce1bf06119420f5bad"}, {file = "importlib_metadata-6.0.0.tar.gz", hash = "sha256:e354bedeb60efa6affdcc8ae121b73544a7aa74156d047311948f6d711cd378d"}, ] +iniconfig = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] +libcst = [ + {file = "libcst-0.4.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f9e42085c403e22201e5c41e707ef73e4ea910ad9fc67983ceee2368097f54e"}, + {file = "libcst-0.4.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1266530bf840cc40633a04feb578bb4cac1aa3aea058cc3729e24eab09a8e996"}, + {file = "libcst-0.4.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9679177391ccb9b0cdde3185c22bf366cb672457c4b7f4031fcb3b5e739fbd6"}, + {file = "libcst-0.4.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d67bc87e0d8db9434f2ea063734938a320f541f4c6da1074001e372f840f385d"}, + {file = "libcst-0.4.9-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e316da5a126f2a9e1d7680f95f907b575f082a35e2f8bd5620c59b2aaaebfe0a"}, + {file = "libcst-0.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:7415569ab998a85b0fc9af3a204611ea7fadb2d719a12532c448f8fc98f5aca4"}, + {file = "libcst-0.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:15ded11ff7f4572f91635e02b519ae959f782689fdb4445bbebb7a3cc5c71d75"}, + {file = "libcst-0.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5b266867b712a120fad93983de432ddb2ccb062eb5fd2bea748c9a94cb200c36"}, + {file = "libcst-0.4.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:045b3b0b06413cdae6e9751b5f417f789ffa410f2cb2815e3e0e0ea6bef10ec0"}, + {file = "libcst-0.4.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e799add8fba4976628b9c1a6768d73178bf898f0ed1bd1322930c2d3db9063ba"}, + {file = "libcst-0.4.9-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10479371d04ee8dc978c889c1774bbf6a83df88fa055fcb0159a606f6679c565"}, + {file = "libcst-0.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:7a98286cbbfa90a42d376900c875161ad02a5a2a6b7c94c0f7afd9075e329ce4"}, + {file = "libcst-0.4.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:400166fc4efb9aa06ce44498d443aa78519082695b1894202dd73cd507d2d712"}, + {file = "libcst-0.4.9-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46123863fba35cc84f7b54dd68826419cabfd9504d8a101c7fe3313ea03776f9"}, + {file = "libcst-0.4.9-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27be8db54c0e5fe440021a771a38b81a7dbc23cd630eb8b0e9828b7717f9b702"}, + {file = "libcst-0.4.9-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:132bec627b064bd567e7e4cd6c89524d02842151eb0d8f5f3f7ffd2579ec1b09"}, + {file = "libcst-0.4.9-cp37-cp37m-win_amd64.whl", hash = "sha256:596860090aeed3ee6ad1e59c35c6c4110a57e4e896abf51b91cae003ec720a11"}, + {file = "libcst-0.4.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f4487608258109f774300466d4ca97353df29ae6ac23d1502e13e5509423c9d5"}, + {file = "libcst-0.4.9-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:aa53993e9a2853efb3ed3605da39f2e7125df6430f613eb67ef886c1ce4f94b5"}, + {file = "libcst-0.4.9-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6ce794483d4c605ef0f5b199a49fb6996f9586ca938b7bfef213bd13858d7ab"}, + {file = "libcst-0.4.9-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:786e562b54bbcd17a060d1244deeef466b7ee07fe544074c252c4a169e38f1ee"}, + {file = "libcst-0.4.9-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:794250d2359edd518fb698e5d21c38a5bdfc5e4a75d0407b4c19818271ce6742"}, + {file = "libcst-0.4.9-cp38-cp38-win_amd64.whl", hash = "sha256:76491f67431318c3145442e97dddcead7075b074c59eac51be7cc9e3fffec6ee"}, + {file = "libcst-0.4.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3cf48d7aec6dc54b02aec0b1bb413c5bb3b02d852fd6facf1f05c7213e61a176"}, + {file = "libcst-0.4.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9b3348c6b7711a5235b133bd8e11d22e903c388db42485b8ceb5f2aa0fae9b9f"}, + {file = "libcst-0.4.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7e33b66762efaa014c38819efae5d8f726dd823e32d5d691035484411d2a2a69"}, + {file = "libcst-0.4.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1350d375d3fb9b20a6cf10c09b2964baca9be753a033dde7c1aced49d8e58387"}, + {file = "libcst-0.4.9-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3822056dc13326082362db35b3f649e0f4a97e36ddb4e487441da8e0fb9db7b3"}, + {file = "libcst-0.4.9-cp39-cp39-win_amd64.whl", hash = "sha256:183636141b839aa35b639e100883813744523bc7c12528906621121731b28443"}, + {file = "libcst-0.4.9.tar.gz", hash = "sha256:01786c403348f76f274dbaf3888ae237ffb73e6ed6973e65eba5c1fc389861dd"}, +] markdown = [ {file = "Markdown-3.4.1-py3-none-any.whl", hash = "sha256:08fb8465cffd03d10b9dd34a5c3fea908e20391a2a90b88d66362cb05beed186"}, {file = "Markdown-3.4.1.tar.gz", hash = "sha256:3b809086bb6efad416156e00a0da66fe47618a5d6918dd688f53f40c8e4cfeff"}, @@ -553,14 +963,26 @@ mypy-extensions = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] +packaging = [ + {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"}, + {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"}, +] pathspec = [ - {file = "pathspec-0.10.3-py3-none-any.whl", hash = "sha256:3c95343af8b756205e2aba76e843ba9520a24dd84f68c22b9f93251507509dd6"}, - {file = "pathspec-0.10.3.tar.gz", hash = "sha256:56200de4077d9d0791465aa9095a01d421861e405b5096955051deefd697d6f6"}, + {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, + {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, ] platformdirs = [ {file = "platformdirs-2.6.2-py3-none-any.whl", hash = "sha256:83c8f6d04389165de7c9b6f0c682439697887bca0aa2f1c87ef1826be3584490"}, {file = "platformdirs-2.6.2.tar.gz", hash = "sha256:e1fea1fe471b9ff8332e229df3cb7de4f53eeea4998d3b6bfff542115e998bd2"}, ] +pluggy = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] +pycln = [ + {file = "pycln-2.1.3-py3-none-any.whl", hash = "sha256:161142502e4ff9853cd462a38401e29eb56235919856df2cb7fa4c84e463717f"}, + {file = "pycln-2.1.3.tar.gz", hash = "sha256:a33bfc64ded74a623b7cf49eca38b58db4348facc60c35af26d45de149b256f5"}, +] pycryptodome = [ {file = "pycryptodome-3.16.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:e061311b02cefb17ea93d4a5eb1ad36dca4792037078b43e15a653a0a4478ead"}, {file = "pycryptodome-3.16.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:dab9359cc295160ba96738ba4912c675181c84bfdf413e5c0621cf00b7deeeaa"}, @@ -631,6 +1053,22 @@ pyhumps = [ {file = "pyhumps-3.8.0-py3-none-any.whl", hash = "sha256:060e1954d9069f428232a1adda165db0b9d8dfdce1d265d36df7fbff540acfd6"}, {file = "pyhumps-3.8.0.tar.gz", hash = "sha256:498026258f7ee1a8e447c2e28526c0bea9407f9a59c03260aee4bd6c04d681a3"}, ] +pytest = [ + {file = "pytest-7.2.1-py3-none-any.whl", hash = "sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5"}, + {file = "pytest-7.2.1.tar.gz", hash = "sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42"}, +] +pytest-cov = [ + {file = "pytest-cov-4.0.0.tar.gz", hash = "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470"}, + {file = "pytest_cov-4.0.0-py3-none-any.whl", hash = "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b"}, +] +pytest-mock = [ + {file = "pytest-mock-3.10.0.tar.gz", hash = "sha256:fbbdb085ef7c252a326fd8cdcac0aa3b1333d8811f131bdcc701002e1be7ed4f"}, + {file = "pytest_mock-3.10.0-py3-none-any.whl", hash = "sha256:f4c973eeae0282963eb293eb173ce91b091a79c1334455acfac9ddee8a1c784b"}, +] +python-dateutil = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] python-dotenv = [ {file = "python-dotenv-0.21.0.tar.gz", hash = "sha256:b77d08274639e3d34145dfa6c7008e66df0f04b7be7a75fd0d5292c191d79045"}, {file = "python_dotenv-0.21.0-py3-none-any.whl", hash = "sha256:1684eb44636dd462b66c3ee016599815514527ad99965de77f43e0944634a7e5"}, @@ -678,8 +1116,12 @@ pyyaml = [ {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, ] requests = [ - {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, - {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, + {file = "requests-2.28.2-py3-none-any.whl", hash = "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa"}, + {file = "requests-2.28.2.tar.gz", hash = "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf"}, +] +rfc3986 = [ + {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, + {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"}, ] sgmllib3k = [ {file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"}, @@ -692,6 +1134,10 @@ sniffio = [ {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, ] +soupsieve = [ + {file = "soupsieve-2.3.2.post1-py3-none-any.whl", hash = "sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759"}, + {file = "soupsieve-2.3.2.post1.tar.gz", hash = "sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d"}, +] starlette = [ {file = "starlette-0.22.0-py3-none-any.whl", hash = "sha256:b5eda991ad5f0ee5d8ce4c4540202a573bb6691ecd0c712262d0bc85cf8f2c50"}, {file = "starlette-0.22.0.tar.gz", hash = "sha256:b092cbc365bea34dd6840b42861bdabb2f507f8671e642e8272d2442e08ea4ff"}, @@ -704,10 +1150,22 @@ tomli = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] +tomlkit = [ + {file = "tomlkit-0.11.6-py3-none-any.whl", hash = "sha256:07de26b0d8cfc18f871aec595fda24d95b08fef89d147caa861939f37230bf4b"}, + {file = "tomlkit-0.11.6.tar.gz", hash = "sha256:71b952e5721688937fb02cf9d354dbcf0785066149d2855e44531ebdd2b65d73"}, +] +typer = [ + {file = "typer-0.7.0-py3-none-any.whl", hash = "sha256:b5e704f4e48ec263de1c0b3a2387cd405a13767d2f907f44c1a08cbad96f606d"}, + {file = "typer-0.7.0.tar.gz", hash = "sha256:ff797846578a9f2a201b53442aedeb543319466870fbe1c701eab66dd7681165"}, +] typing-extensions = [ {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, ] +typing-inspect = [ + {file = "typing_inspect-0.8.0-py3-none-any.whl", hash = "sha256:5fbf9c1e65d4fa01e701fe12a5bca6c6e08a4ffd5bc60bfac028253a447c5188"}, + {file = "typing_inspect-0.8.0.tar.gz", hash = "sha256:8b1ff0c400943b6145df8119c41c244ca8207f1f10c9c057aeed1560e4806e3d"}, +] urllib3 = [ {file = "urllib3-1.26.13-py2.py3-none-any.whl", hash = "sha256:47cc05d99aaa09c9e72ed5809b60e7ba354e64b59c9c173ac3018642d8bb41fc"}, {file = "urllib3-1.26.13.tar.gz", hash = "sha256:c083dd0dce68dbfbe1129d5271cb90f9447dea7d52097c6e0126120c521ddea8"}, diff --git a/pyproject.toml b/pyproject.toml index 58676c7..2ad17e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ f2ap = "f2ap.__main__:main" python = "^3.9" feedparser = "^6.0" toml = "^0.10.2" -fastapi = "^0.88.0" +fastapi = ">=0.88,<0.90" uvicorn = {extras = ["standard"], version = "^0.20.0"} pyhumps = "^3.8.0" requests = "^2.28.1" @@ -31,9 +31,17 @@ pycryptodome = "^3.16.0" markdown = "^3.4.1" mdx-linkify = "^2.1" pydantic = "^1.10.2" +beautifulsoup4 = "^4.11.1" +python-dateutil = "^2.8.2" [tool.poetry.group.dev.dependencies] black = "^22.10.0" +pycln = "^2.1.2" +pytest = "^7.2.0" +pytest-cov = "^4.0.0" +pytest-mock = "^3.10.0" +coveralls = "^3.3.1" +httpx = "^0.23.3" [build-system] requires = ["poetry-core"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..fdb477e --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,42 @@ +from os import path, remove +from uuid import uuid4 + +from f2ap.config import Configuration +from f2ap.data import Database + + +PATH_DIRNAME = path.dirname(__file__) + + +def get_fake_db(db_path: str = None, delete_first: bool = False) -> Database: + if db_path is None: + db_path = f"/files/database.{uuid4().hex}.db" + + realpath = f"{PATH_DIRNAME}/{db_path}" + if delete_first and path.exists(realpath): + remove(realpath) + + return Database(get_fake_config(db_path)) + + +def get_fake_config(db_path: str): + return Configuration( + url="example.com", + db=f"{PATH_DIRNAME}/{db_path}", + website={ + "url": "https://example.com/blog", + "feed": "https://example.com/blog.feed", + }, + actor={ + "username": "test", + "display_name": "The Test Profile", + "summary": "What did you expect?", + "followings": ["@coolperson@example.org"], + "attachments": { + "Website": "https://example.com", + }, + "public_key": f"{PATH_DIRNAME}/files/fake-rsa/public", + "private_key": f"{PATH_DIRNAME}/files/fake-rsa/private", + }, + message={"format": ""}, + ) diff --git a/tests/_markdown.py b/tests/_markdown.py new file mode 100644 index 0000000..708defd --- /dev/null +++ b/tests/_markdown.py @@ -0,0 +1,80 @@ +import pytest + +from f2ap import markdown + + +def test_find_hashtags(): + assert markdown.find_hashtags("Hello World! #test #pytest #python #hello") == [ + "test", + "pytest", + "python", + "hello", + ] + + +@pytest.mark.parametrize( + [ + "expected_return", + "text", + "one_paragraph", + "nl2br", + "autolink", + "parse_fediverse_tags", + ], + [ + ( + '

Hello\nWorld!

', + "Hello\n[World](https://en.wikipedia.org/wiki/World)!", + False, + False, + False, + False, + ), + ( + 'Hello\nWorld!', + "Hello\n[World](https://en.wikipedia.org/wiki/World)!", + True, + False, + False, + False, + ), + ( + '

Hello
\nWorld!

', + "Hello\n[World](https://en.wikipedia.org/wiki/World)!", + False, + True, + False, + False, + ), + ( + '

Hello\nWorld: https://en.wikipedia.org/wiki/World!

', + "Hello\nWorld: https://en.wikipedia.org/wiki/World!", + False, + False, + True, + False, + ), + ( + '

Hello\n@Earth@solarsystem.org!

', + "Hello\n@Earth@solarsystem.org!", + False, + False, + False, + True, + ), + ], +) +def test_parse_markdown( + expected_return: str, + text: str, + one_paragraph: bool, + nl2br: bool, + autolink: bool, + parse_fediverse_tags: bool, +): + assert ( + markdown.parse_markdown( + text, one_paragraph, nl2br, autolink, parse_fediverse_tags + ) + == expected_return + ) diff --git a/tests/activitypub.py b/tests/activitypub.py new file mode 100644 index 0000000..81e7d10 --- /dev/null +++ b/tests/activitypub.py @@ -0,0 +1,87 @@ +from uuid import uuid4 +from pytest_mock import MockerFixture + +from . import get_fake_db + +from f2ap import activitypub +from f2ap.enum import Visibility +from f2ap.model import W3C_ACTIVITYSTREAMS_PUBLIC, Note + + +def test_get_note_visibility_public_note(): + activitypub.get_actor = lambda s: {"followers": f"{s}/followers"} + + assert Visibility.PUBLIC == activitypub.get_note_visibility( + { + "attributedTo": "https://example.com/users/actor", + "to": ["https://example.org/users/test"], + "cc": [W3C_ACTIVITYSTREAMS_PUBLIC], + } + ) + + assert Visibility.PUBLIC == activitypub.get_note_visibility( + { + "attributedTo": "https://example.com/users/actor", + "to": ["https://example.org/users/test", W3C_ACTIVITYSTREAMS_PUBLIC], + "cc": [], + } + ) + + +def test_get_note_visibility_followers_only_note(): + activitypub.get_actor = lambda s: {"followers": f"{s}/followers"} + + assert Visibility.FOLLOWERS_ONLY == activitypub.get_note_visibility( + { + "attributedTo": "https://example.com/users/actor", + "to": ["https://example.org/users/test"], + "cc": ["https://example.com/users/actor/followers"], + } + ) + + assert Visibility.FOLLOWERS_ONLY == activitypub.get_note_visibility( + { + "attributedTo": "https://example.com/users/actor", + "to": [ + "https://example.org/users/test", + "https://example.com/users/actor/followers", + ], + "cc": [], + } + ) + + +def test_get_note_visibility_mentioned_only_note(): + activitypub.get_actor = lambda s: {"followers": f"{s}/followers"} + + assert Visibility.MENTIONED_ONLY == Visibility.DIRECT_MESSAGE + + assert Visibility.MENTIONED_ONLY == activitypub.get_note_visibility( + { + "attributedTo": "https://example.com/users/actor", + "to": ["https://example.org/users/test"], + "cc": ["https://example.net/users/anotherUser"], + } + ) + + +def test_inbox_can_handle_comments(mocker: MockerFixture): + db = get_fake_db() + comment_uuid = uuid4() + mocker.patch.object(db, "insert_comment", return_value=comment_uuid) + + activitypub.handle_inbox() + + replying_to = Note( + uuid=comment_uuid, + id=42, + name= + ) + url = n + published_on = n + author_url = n + content = n + visibility = n + tags = [] + + db.insert_comment.assert_called_with(replying_to, url, published_on, author_url, content, visibility, tags) diff --git a/tests/data.py b/tests/data.py new file mode 100644 index 0000000..59855b9 --- /dev/null +++ b/tests/data.py @@ -0,0 +1,47 @@ +import pytest + +from os import utime +from shutil import copyfile + +from f2ap.data import TABLES + +from . import PATH_DIRNAME, get_fake_db + + +def test_init_database(): + db_path = "/files/database.db" + + db = get_fake_db(db_path, True) + db.init_database() + + assert db.get_schema_info() == TABLES + + +def test_init_database_fails_if_file_exists(): + db_path = "/files/database.db" + + # Ensure the file exists (equivalent to the UNIX `touch` command) + with open(f"{PATH_DIRNAME}{db_path}", "a"): + utime(f"{PATH_DIRNAME}{db_path}") + + db = get_fake_db(db_path) + + assert db.is_database_initialized() + + with pytest.raises(IOError) as error: + db.init_database() + assert ( + error.value + == "Database already exists. If you want to reinitialize the data, delete it or rename it first." + ) + + +def test_upgrade_database(): + v1_path = "/files/database/upgrade/database-v1.db" + db_path = "/files/database/upgrade/database.db" + + copyfile(f"{PATH_DIRNAME}/{v1_path}", f"{PATH_DIRNAME}{db_path}") + db = get_fake_db(db_path) + + assert db.upgrade_database() + assert db.get_schema_info() == TABLES diff --git a/tests/files/database/database.sha256 b/tests/files/database/database.sha256 new file mode 100644 index 0000000..ec30477 --- /dev/null +++ b/tests/files/database/database.sha256 @@ -0,0 +1 @@ +8b6d7b3d90d141f8965104a6cc2473d2e16ea5bed42f058201d235fb669490de diff --git a/tests/files/database/upgrade/database-v1.db b/tests/files/database/upgrade/database-v1.db new file mode 100644 index 0000000..3d676d9 Binary files /dev/null and b/tests/files/database/upgrade/database-v1.db differ diff --git a/tests/files/fake-rsa/private b/tests/files/fake-rsa/private new file mode 100644 index 0000000..e69de29 diff --git a/tests/files/fake-rsa/public b/tests/files/fake-rsa/public new file mode 100644 index 0000000..e69de29 diff --git a/tests/h_tml.py b/tests/h_tml.py new file mode 100644 index 0000000..dd3286c --- /dev/null +++ b/tests/h_tml.py @@ -0,0 +1,35 @@ +# Note: this file purposely has an extra underscore in its name. +# This is a fix to this issue: +# https://stackoverflow.com/questions/27372347/beautifulsoup-importerror-no-module-named-html-entities + +from f2ap import html + + +def test_sanitize_with_default_authorized_tags(): + original = '

Hello World!

' + expected = '

Hello World!

' + + assert expected == html.sanitize(original) + + +def test_sanitize_with_custom_tags(): + original = '

Hello World!

' + expected = "Hello World!" + + assert expected == html.sanitize(original, keep=["strong"]) + + +def test_sanitize_with_no_custom_tags(): + original = '

Hello World!

' + expected = "Hello World!" + + assert expected == html.sanitize(original, keep=[]) + + +def test_sanitize_removes_comments(): + original = ( + '

World!

' + ) + expected = '

World!

' + + assert expected == html.sanitize(original) diff --git a/tests/webserver.py b/tests/webserver.py new file mode 100644 index 0000000..d6bc55c --- /dev/null +++ b/tests/webserver.py @@ -0,0 +1,27 @@ +from fastapi.testclient import TestClient + +from f2ap import webserver + +from . import get_fake_config, get_fake_db + +DATABASE_FILE = "files/webserver.db" + +get_fake_db(DATABASE_FILE, delete_first=True).init_database() +client = TestClient(webserver.get_server(get_fake_config(DATABASE_FILE))) + + +def test_webfinger(): + response = client.get("/.well-known/webfinger?resource=acct:test@example.com") + + assert response.status_code == 200 + assert response.headers.get("Content-Type") == "application/jrd+json" + assert response.json() == { + "subject": "acct:test@example.com", + "links": [ + { + "rel": "self", + "type": "application/activity+json", + "href": "https://example.com/actors/test", + } + ], + }