Compare commits

...

9 commits

Author SHA1 Message Date
Izalia Mae 5ae3d55e44 Merge branch 'sql' into 'master'
Draft: switch database backend to sql

See merge request pleroma/relay!53
2024-01-24 06:21:49 +00:00
Izalia Mae 815053c06f fix the convert command 2024-01-24 01:20:23 -05:00
Izalia Mae e66be009a6 use the right name for the domain_bans table 2024-01-24 01:20:00 -05:00
Izalia Mae 09e7a8f404 update docs for new commands and config file 2024-01-24 00:48:15 -05:00
Izalia Mae fc8738afab update relay.service file to use run command 2024-01-23 22:04:07 -05:00
Izalia Mae cdb10547ec remove extra whitespace in relay.nginx 2024-01-23 22:03:44 -05:00
Izalia Mae 7a9d346642 fix linter warnings 2024-01-23 21:54:58 -05:00
Izalia Mae 485d1cd23e add plugins to pylint 2024-01-23 21:54:05 -05:00
Izalia Mae 35b3fae185 move dev requirements to dev-requirements.txt and only use flake8 for checking unused imports 2024-01-23 21:51:17 -05:00
18 changed files with 199 additions and 176 deletions

3
dev-requirements.txt Normal file
View file

@ -0,0 +1,3 @@
flake8 == 7.0.0
pyinstaller == 6.3.0
pylint == 3.0

View file

@ -3,11 +3,8 @@
There are a number of commands to manage your relay's database and config. You can add `--help` to
any category or command to get help on that specific option (ex. `activityrelay inbox --help`).
Note: Unless specified, it is recommended to run any commands while the relay is shutdown.
Note 2: `activityrelay` is only available via pip or pipx if `~/.local/bin` is in `$PATH`. If it
isn't, use `python3 -m relay` if installed via pip or `~/.local/bin/activityrelay` if installed
via pipx
Note: `activityrelay` is only available via pip or pipx if `~/.local/bin` is in `$PATH`. If not,
use `python3 -m relay` if installed via pip or `~/.local/bin/activityrelay` if installed via pipx.
## Run
@ -24,6 +21,22 @@ Run the setup wizard to configure your relay.
activityrelay setup
## Convert
Convert the old config and jsonld to the new config and SQL backend. If the old config filename is
not specified, the config will get backed up as `relay.backup.yaml` before converting.
activityrelay convert --old-config relaycfg.yaml
## Edit Config
Open the config file in a text editor. If an editor is not specified with `--editor`, the default
editor will be used.
activityrelay edit-config --editor micro
## Config
Manage the relay config
@ -120,7 +133,7 @@ Remove a domain from the whitelist.
### Import
Add all current inboxes to the whitelist
Add all current inboxes to the whitelist.
activityrelay whitelist import
@ -132,15 +145,15 @@ Manage the instance ban list.
### List
List the currently banned instances
List the currently banned instances.
activityrelay instance list
### Ban
Add an instance to the ban list. If the instance is currently subscribed, remove it from the
database.
Add an instance to the ban list. If the instance is currently subscribed, it will be removed from
the inbox list.
activityrelay instance ban <domain>
@ -152,10 +165,17 @@ Remove an instance from the ban list.
activityrelay instance unban <domain>
### Update
Update the ban reason or note for an instance ban.
activityrelay instance update bad.example.com --reason "the baddest reason"
## Software
Manage the software ban list. To get the correct name, check the software's nodeinfo endpoint.
You can find it at nodeinfo\['software']\['name'].
You can find it at `nodeinfo['software']['name']`.
### List
@ -186,4 +206,12 @@ name via nodeinfo.
If the name is `RELAYS` (case-sensitive), remove all known relay software names from the list.
activityrelay unban [-f/--fetch-nodeinfo] <name, domain, or RELAYS>
activityrelay software unban [-f/--fetch-nodeinfo] <name, domain, or RELAYS>
### Update
Update the ban reason or note for a software ban. Either `--reason` and/or `--note` must be
specified.
activityrelay software update relay.example.com --reason "begone relay"

View file

@ -2,41 +2,23 @@
## General
### DB
### Domain
The path to the database. It contains the relay actor private key and all subscribed
instances. If the path is not absolute, it is relative to the working directory.
Hostname the relay will be hosted on.
db: relay.jsonld
domain: relay.example.com
### Listener
The address and port the relay will listen on. If the reverse proxy (nginx, apache, caddy, etc)
is running on the same host, it is recommended to change `listen` to `localhost`
is running on the same host, it is recommended to change `listen` to `localhost` if the reverse
proxy is on the same host.
listen: 0.0.0.0
port: 8080
### Note
A small blurb to describe your relay instance. This will show up on the relay's home page.
note: "Make a note about your instance here."
### Post Limit
The maximum number of messages to send out at once. For each incoming message, a message will be
sent out to every subscribed instance minus the instance which sent the message. This limit
is to prevent too many outgoing connections from being made, so adjust if necessary.
Note: If the `workers` option is set to anything above 0, this limit will be per worker.
push_limit: 512
### Push Workers
The relay can be configured to use threads to push messages out. For smaller relays, this isn't
@ -46,60 +28,59 @@ threads.
workers: 0
### JSON GET cache limit
### Database type
JSON objects (actors, nodeinfo, etc) will get cached when fetched. This will set the max number of
objects to keep in the cache.
SQL database backend to use. Valid values are `sqlite` or `postgres`.
json_cache: 1024
database_type: sqlite
## AP
### Sqlite File Path
Various ActivityPub-related settings
Path to the sqlite database file. If the path is not absolute, it is relative to the config file.
directory.
sqlite_path: relay.jsonld
## Postgresql
In order to use the Postgresql backend, the user and database need to be created first.
sudo -u postgres psql -c "CREATE USER activityrelay"
sudo -u postgres psql -c "CREATE DATABASE activityrelay OWNER activityrelay"
### Database Name
Name of the database to use.
name: activityrelay
### Host
The domain your relay will use to identify itself.
Hostname, IP address, or unix socket the server is hosted on.
host: relay.example.com
host: /var/run/postgresql
### Whitelist Enabled
### Port
If set to `true`, only instances in the whitelist can follow the relay. Any subscribed instances
not in the whitelist will be removed from the inbox list on startup.
Port number the server is listening on.
whitelist_enabled: false
port: 5432
### Whitelist
### Username
A list of domains of instances which are allowed to subscribe to your relay.
User to use when logging into the server.
whitelist:
- bad-instance.example.com
- another-bad-instance.example.com
user: null
### Blocked Instances
### Password
A list of instances which are unable to follow the instance. If a subscribed instance is added to
the block list, it will be removed from the inbox list on startup.
Password for the specified user.
blocked_instances:
- bad-instance.example.com
- another-bad-instance.example.com
### Blocked Software
A list of ActivityPub software which cannot follow your relay. This list is empty by default, but
setting this to the below list will block all other relays and prevent relay chains
blocked_software:
- activityrelay
- aoderelay
- social.seattle.wa.us-relay
- unciarelay
pass: null

View file

@ -28,14 +28,14 @@ server {
# logging, mostly for debug purposes. Disable if you wish.
access_log /srv/www/relay.<yourdomain>/logs/access.log;
error_log /srv/www/relay.<yourdomain>/logs/error.log;
ssl_protocols TLSv1.2;
ssl_ciphers EECDH+AESGCM:EECDH+AES;
ssl_ecdh_curve secp384r1;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
# ssl certs.
# ssl certs.
ssl_certificate /usr/local/etc/letsencrypt/live/relay.<yourdomain>/fullchain.pem;
ssl_certificate_key /usr/local/etc/letsencrypt/live/relay.<yourdomain>/privkey.pem;
@ -48,7 +48,7 @@ server {
# sts, change if you care.
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";
# uncomment this to use a static page in your webroot for your root page.
#location = / {
# index index.html;

View file

@ -3,7 +3,7 @@ Description=ActivityPub Relay
[Service]
WorkingDirectory=/home/relay/relay
ExecStart=/usr/bin/python3 -m relay
ExecStart=/usr/bin/python3 -m relay run
[Install]
WantedBy=multi-user.target

View file

@ -6,6 +6,23 @@ build-backend = 'setuptools.build_meta'
[tool.pylint.main]
jobs = 0
persistent = true
load-plugins = [
"pylint.extensions.code_style",
"pylint.extensions.comparison_placement",
"pylint.extensions.confusing_elif",
"pylint.extensions.for_any_all",
"pylint.extensions.consider_ternary_expression",
"pylint.extensions.bad_builtin",
"pylint.extensions.dict_init_mutate",
"pylint.extensions.check_elif",
"pylint.extensions.empty_comment",
"pylint.extensions.private_import",
"pylint.extensions.redefined_variable_type",
"pylint.extensions.no_self_use",
"pylint.extensions.overlapping_exceptions",
"pylint.extensions.set_membership",
"pylint.extensions.typing"
]
[tool.pylint.design]
@ -22,6 +39,7 @@ single-line-if-stmt = true
[tool.pylint.messages_control]
disable = [
"fixme",
"broad-exception-caught",
"cyclic-import",
"global-statement",
@ -31,7 +49,8 @@ disable = [
"too-many-public-methods",
"too-many-return-statements",
"wrong-import-order",
"wrong-import-position",
"missing-function-docstring",
"missing-class-docstring"
"missing-class-docstring",
"consider-using-namedtuple-or-dataclass",
"confusing-consecutive-elif"
]

View file

@ -13,7 +13,8 @@ from . import logger as logging
from .misc import Message, boolean
if typing.TYPE_CHECKING:
from typing import Any, Iterator, Optional
from collections.abc import Iterator
from typing import Any
# pylint: disable=duplicate-code
@ -30,10 +31,10 @@ class RelayConfig(dict):
def __setitem__(self, key: str, value: Any) -> None:
if key in ['blocked_instances', 'blocked_software', 'whitelist']:
if key in {'blocked_instances', 'blocked_software', 'whitelist'}:
assert isinstance(value, (list, set, tuple))
elif key in ['port', 'workers', 'json_cache', 'timeout']:
elif key in {'port', 'workers', 'json_cache', 'timeout'}:
if not isinstance(value, int):
value = int(value)
@ -110,7 +111,7 @@ class RelayConfig(dict):
return
for key, value in config.items():
if key in ['ap']:
if key == 'ap':
for k, v in value.items():
if k not in self:
continue
@ -190,7 +191,7 @@ class RelayDatabase(dict):
json.dump(self, fd, indent=4)
def get_inbox(self, domain: str, fail: Optional[bool] = False) -> dict[str, str] | None:
def get_inbox(self, domain: str, fail: bool = False) -> dict[str, str] | None:
if domain.startswith('http'):
domain = urlparse(domain).hostname
@ -205,14 +206,13 @@ class RelayDatabase(dict):
def add_inbox(self,
inbox: str,
followid: Optional[str] = None,
software: Optional[str] = None) -> dict[str, str]:
followid: str | None = None,
software: str | None = None) -> dict[str, str]:
assert inbox.startswith('https'), 'Inbox must be a url'
domain = urlparse(inbox).hostname
instance = self.get_inbox(domain)
if instance:
if (instance := self.get_inbox(domain)):
if followid:
instance['followid'] = followid
@ -234,12 +234,10 @@ class RelayDatabase(dict):
def del_inbox(self,
domain: str,
followid: Optional[str] = None,
fail: Optional[bool] = False) -> bool:
followid: str = None,
fail: bool = False) -> bool:
data = self.get_inbox(domain, fail=False)
if not data:
if not (data := self.get_inbox(domain, fail=False)):
if fail:
raise KeyError(domain)

View file

@ -10,7 +10,7 @@ from pathlib import Path
from .misc import IS_DOCKER
if typing.TYPE_CHECKING:
from typing import Any, Optional
from typing import Any
DEFAULTS: dict[str, Any] = {
@ -32,7 +32,7 @@ if IS_DOCKER:
class Config:
def __init__(self, path: str, load: Optional[bool] = False):
def __init__(self, path: str, load: bool = False):
self.path = Path(path).expanduser().resolve()
self.listen = None
@ -151,7 +151,7 @@ class Config:
if key not in DEFAULTS:
raise KeyError(key)
if key in ('port', 'pg_port', 'workers') and not isinstance(value, int):
if key in {'port', 'pg_port', 'workers'} and not isinstance(value, int):
value = int(value)
setattr(self, key, value)

View file

@ -12,11 +12,10 @@ from .schema import VERSIONS, migrate_0
from .. import logger as logging
if typing.TYPE_CHECKING:
from typing import Optional
from .config import Config
def get_database(config: Config, migrate: Optional[bool] = True) -> tinysql.Database:
def get_database(config: Config, migrate: bool = True) -> tinysql.Database:
if config.db_type == "sqlite":
db = tinysql.Database.sqlite(config.sqlite_path, connection_class = Connection)
@ -41,9 +40,7 @@ def get_database(config: Config, migrate: Optional[bool] = True) -> tinysql.Data
migrate_0(conn)
return db
schema_ver = conn.get_config('schema-version')
if schema_ver < get_default_value('schema-version'):
if (schema_ver := conn.get_config('schema-version')) < get_default_value('schema-version'):
logging.info("Migrating database from version '%i'", schema_ver)
for ver, func in VERSIONS:

View file

@ -6,7 +6,8 @@ from .. import logger as logging
from ..misc import boolean
if typing.TYPE_CHECKING:
from typing import Any, Callable
from collections.abc import Callable
from typing import Any
CONFIG_DEFAULTS: dict[str, tuple[str, Any]] = {

View file

@ -12,8 +12,9 @@ from .. import logger as logging
from ..misc import get_app
if typing.TYPE_CHECKING:
from collections.abc import Iterator
from tinysql import Cursor, Row
from typing import Any, Iterator, Optional
from typing import Any
from .application import Application
from ..misc import Message
@ -43,7 +44,7 @@ class Connection(tinysql.Connection):
yield inbox['inbox']
def exec_statement(self, name: str, params: Optional[dict[str, Any]] = None) -> Cursor:
def exec_statement(self, name: str, params: dict[str, Any] | None = None) -> Cursor:
return self.execute(self.database.prepared_statements[name], params)
@ -110,9 +111,9 @@ class Connection(tinysql.Connection):
def put_inbox(self,
domain: str,
inbox: str,
actor: Optional[str] = None,
followid: Optional[str] = None,
software: Optional[str] = None) -> Row:
actor: str | None = None,
followid: str | None = None,
software: str | None = None) -> Row:
params = {
'domain': domain,
@ -129,9 +130,9 @@ class Connection(tinysql.Connection):
def update_inbox(self,
inbox: str,
actor: Optional[str] = None,
followid: Optional[str] = None,
software: Optional[str] = None) -> Row:
actor: str | None = None,
followid: str | None = None,
software: str | None = None) -> Row:
if not (actor or followid or software):
raise ValueError('Missing "actor", "followid", and/or "software"')
@ -171,8 +172,8 @@ class Connection(tinysql.Connection):
def put_domain_ban(self,
domain: str,
reason: Optional[str] = None,
note: Optional[str] = None) -> Row:
reason: str | None = None,
note: str | None = None) -> Row:
params = {
'domain': domain,
@ -187,8 +188,8 @@ class Connection(tinysql.Connection):
def update_domain_ban(self,
domain: str,
reason: Optional[str] = None,
note: Optional[str] = None) -> tinysql.Row:
reason: str | None = None,
note: str | None = None) -> tinysql.Row:
if not (reason or note):
raise ValueError('"reason" and/or "note" must be specified')
@ -225,8 +226,8 @@ class Connection(tinysql.Connection):
def put_software_ban(self,
name: str,
reason: Optional[str] = None,
note: Optional[str] = None) -> Row:
reason: str | None = None,
note: str | None = None) -> Row:
params = {
'name': name,
@ -241,8 +242,8 @@ class Connection(tinysql.Connection):
def update_software_ban(self,
name: str,
reason: Optional[str] = None,
note: Optional[str] = None) -> tinysql.Row:
reason: str | None = None,
note: str | None = None) -> tinysql.Row:
if not (reason or note):
raise ValueError('"reason" and/or "note" must be specified')

View file

@ -7,7 +7,7 @@ from tinysql import Column, Connection, Table
from .config import get_default_value
if typing.TYPE_CHECKING:
from typing import Callable
from collections.abc import Callable
VERSIONS: list[Callable] = []
@ -33,7 +33,7 @@ TABLES: list[Table] = [
Column('created', 'timestamp')
),
Table(
'instance_bans',
'domain_bans',
Column('domain', 'text', primary_key = True, unique = True, nullable = True),
Column('reason', 'text'),
Column('note', 'text'),

View file

@ -16,7 +16,7 @@ from . import logger as logging
from .misc import MIMETYPES, Message, get_app
if typing.TYPE_CHECKING:
from typing import Any, Callable, Optional
from typing import Any
HEADERS = {
@ -26,11 +26,7 @@ HEADERS = {
class HttpClient:
def __init__(self,
limit: Optional[int] = 100,
timeout: Optional[int] = 10,
cache_size: Optional[int] = 1024):
def __init__(self, limit: int = 100, timeout: int = 10, cache_size: int = 1024):
self.cache = LRUCache(cache_size)
self.limit = limit
self.timeout = timeout
@ -77,9 +73,9 @@ class HttpClient:
async def get(self, # pylint: disable=too-many-branches
url: str,
sign_headers: Optional[bool] = False,
loads: Optional[Callable] = None,
force: Optional[bool] = False) -> Message | dict | None:
sign_headers: bool = False,
loads: callable | None = None,
force: bool = False) -> Message | dict | None:
await self.open()
@ -151,11 +147,13 @@ class HttpClient:
instance = conn.get_inbox(url)
## Using the old algo by default is probably a better idea right now
# pylint: disable=consider-ternary-expression
if instance and instance['software'] in {'mastodon'}:
algorithm = 'hs2019'
else:
algorithm = 'original'
# pylint: enable=consider-ternary-expression
headers = {'Content-Type': 'application/activity+json'}
headers.update(get_app().signer.sign_headers('POST', url, message, algorithm=algorithm))
@ -195,7 +193,7 @@ class HttpClient:
logging.verbose('Failed to fetch well-known nodeinfo url for %s', domain)
return None
for version in ['20', '21']:
for version in ('20', '21'):
try:
nodeinfo_url = wk_nodeinfo.get_url(version)

View file

@ -8,7 +8,8 @@ from enum import IntEnum
from pathlib import Path
if typing.TYPE_CHECKING:
from typing import Any, Callable, Type
from collections.abc import Callable
from typing import Any
class LogLevel(IntEnum):
@ -25,7 +26,7 @@ class LogLevel(IntEnum):
@classmethod
def parse(cls: Type[IntEnum], data: object) -> IntEnum:
def parse(cls: type[IntEnum], data: object) -> IntEnum:
if isinstance(data, cls):
return data

View file

@ -22,7 +22,7 @@ from .misc import IS_DOCKER, Message, check_open_port
if typing.TYPE_CHECKING:
from tinysql import Row
from typing import Any, Optional
from typing import Any
# pylint: disable=unsubscriptable-object,unsupported-assignment-operation
@ -69,7 +69,7 @@ def cli(ctx: click.Context, config: str) -> None:
@cli.command('setup')
@click.pass_context
def cli_setup(ctx: click.Context) -> None:
'Generate a new config'
'Generate a new config and create the database'
while True:
ctx.obj.config.domain = click.prompt(
@ -184,12 +184,12 @@ def cli_run(ctx: click.Context) -> None:
@cli.command('convert')
@click.option('--old-config', '-o', help = 'Path to the new config file')
@click.option('--old-config', '-o', help = 'Path to the config file to convert from')
@click.pass_context
def cli_convert(ctx: click.Context, old_config: str) -> None:
'Convert an old config and jsonld database to the new format.'
old_config = Path(old_config).expanduser().resolve()
old_config = Path(old_config).expanduser().resolve() if old_config else ctx.obj.config.path
backup = ctx.obj.config.path.parent.joinpath(f'{ctx.obj.config.path.stem}.backup.yaml')
if str(old_config) == str(ctx.obj.config.path) and not backup.exists():
@ -206,6 +206,8 @@ def cli_convert(ctx: click.Context, old_config: str) -> None:
ctx.obj.config.set('port', config['port'])
ctx.obj.config.set('workers', config['workers'])
ctx.obj.config.set('sq_path', config['db'].replace('jsonld', 'sqlite3'))
ctx.obj.config.set('domain', config['host'])
ctx.obj.config.save()
with get_database(ctx.obj.config) as db:
with db.connection() as conn:
@ -220,7 +222,7 @@ def cli_convert(ctx: click.Context, old_config: str) -> None:
) as inboxes:
for inbox in inboxes:
if inbox['software'] in ('akkoma', 'pleroma'):
if inbox['software'] in {'akkoma', 'pleroma'}:
actor = f'https://{inbox["domain"]}/relay'
elif inbox['software'] == 'mastodon':
@ -349,9 +351,7 @@ def cli_inbox_follow(ctx: click.Context, actor: str) -> None:
if not actor.startswith('http'):
actor = f'https://{actor}/actor'
actor_data = asyncio.run(http.get(actor, sign_headers = True))
if not actor_data:
if not (actor_data := asyncio.run(http.get(actor, sign_headers = True))):
click.echo(f'Failed to fetch actor: {actor}')
return
@ -411,14 +411,17 @@ def cli_inbox_unfollow(ctx: click.Context, actor: str) -> None:
@click.argument('inbox')
@click.option('--actor', '-a', help = 'Actor url for the inbox')
@click.option('--followid', '-f', help = 'Url for the follow activity')
@click.option('--software', '-s', type = click.Choice(SOFTWARE))
@click.option('--software', '-s',
type = click.Choice(SOFTWARE),
help = 'Nodeinfo software name of the instance'
) # noqa: E124
@click.pass_context
def cli_inbox_add(
ctx: click.Context,
inbox: str,
actor: Optional[str] = None,
followid: Optional[str] = None,
software: Optional[str] = None) -> None:
actor: str | None = None,
followid: str | None = None,
software: str | None = None) -> None:
'Add an inbox to the database'
if not inbox.startswith('http'):
@ -428,6 +431,10 @@ def cli_inbox_add(
else:
domain = urlparse(inbox).netloc
if not software:
if (nodeinfo := asyncio.run(http.fetch_nodeinfo(domain))):
software = nodeinfo.sw_name
if not actor and software:
try:
actor = ACTOR_FORMATS[software].format(domain = domain)
@ -592,9 +599,7 @@ def cli_software_ban(ctx: click.Context,
return
if fetch_nodeinfo:
nodeinfo = asyncio.run(http.fetch_nodeinfo(name))
if not nodeinfo:
if not (nodeinfo := asyncio.run(http.fetch_nodeinfo(name))):
click.echo(f'Failed to fetch software name from domain: {name}')
return
@ -634,9 +639,7 @@ def cli_software_unban(ctx: click.Context, name: str, fetch_nodeinfo: bool) -> N
return
if fetch_nodeinfo:
nodeinfo = asyncio.run(http.fetch_nodeinfo(name))
if not nodeinfo:
if not (nodeinfo := asyncio.run(http.fetch_nodeinfo(name))):
click.echo(f'Failed to fetch software name from domain: {name}')
return

View file

@ -14,7 +14,8 @@ from functools import cached_property
from uuid import uuid4
if typing.TYPE_CHECKING:
from typing import Any, Coroutine, Generator, Optional, Type
from collections.abc import Coroutine, Generator
from typing import Any
from .application import Application
from .config import Config
from .database import Database
@ -37,10 +38,10 @@ NODEINFO_NS = {
def boolean(value: Any) -> bool:
if isinstance(value, str):
if value.lower() in ['on', 'y', 'yes', 'true', 'enable', 'enabled', '1']:
if value.lower() in {'on', 'y', 'yes', 'true', 'enable', 'enabled', '1'}:
return True
if value.lower() in ['off', 'n', 'no', 'false', 'disable', 'disable', '0']:
if value.lower() in {'off', 'n', 'no', 'false', 'disable', 'disabled', '0'}:
return False
raise TypeError(f'Cannot parse string "{value}" as a boolean')
@ -83,10 +84,10 @@ def get_app() -> Application:
class Message(ApMessage):
@classmethod
def new_actor(cls: Type[Message], # pylint: disable=arguments-differ
def new_actor(cls: type[Message], # pylint: disable=arguments-differ
host: str,
pubkey: str,
description: Optional[str] = None) -> Message:
description: str | None = None) -> Message:
return cls({
'@context': 'https://www.w3.org/ns/activitystreams',
@ -111,7 +112,7 @@ class Message(ApMessage):
@classmethod
def new_announce(cls: Type[Message], host: str, obj: str) -> Message:
def new_announce(cls: type[Message], host: str, obj: str) -> Message:
return cls({
'@context': 'https://www.w3.org/ns/activitystreams',
'id': f'https://{host}/activities/{uuid4()}',
@ -123,7 +124,7 @@ class Message(ApMessage):
@classmethod
def new_follow(cls: Type[Message], host: str, actor: str) -> Message:
def new_follow(cls: type[Message], host: str, actor: str) -> Message:
return cls({
'@context': 'https://www.w3.org/ns/activitystreams',
'type': 'Follow',
@ -135,7 +136,7 @@ class Message(ApMessage):
@classmethod
def new_unfollow(cls: Type[Message], host: str, actor: str, follow: str) -> Message:
def new_unfollow(cls: type[Message], host: str, actor: str, follow: str) -> Message:
return cls({
'@context': 'https://www.w3.org/ns/activitystreams',
'id': f'https://{host}/activities/{uuid4()}',
@ -147,7 +148,7 @@ class Message(ApMessage):
@classmethod
def new_response(cls: Type[Message],
def new_response(cls: type[Message],
host: str,
actor: str,
followid: str,
@ -180,11 +181,11 @@ class Message(ApMessage):
class Response(AiohttpResponse):
@classmethod
def new(cls: Type[Response],
body: Optional[str | bytes | dict] = '',
status: Optional[int] = 200,
headers: Optional[dict[str, str]] = None,
ctype: Optional[str] = 'text') -> Response:
def new(cls: type[Response],
body: str | bytes | dict = '',
status: int = 200,
headers: dict[str, str] | None = None,
ctype: str = 'text') -> Response:
kwargs = {
'status': status,
@ -205,7 +206,7 @@ class Response(AiohttpResponse):
@classmethod
def new_error(cls: Type[Response],
def new_error(cls: type[Response],
status: int,
body: str | bytes | dict,
ctype: str = 'text') -> Response:
@ -228,12 +229,10 @@ class Response(AiohttpResponse):
class View(AbstractView):
def __await__(self) -> Generator[Response]:
method = self.request.method.upper()
if (self.request.method) not in METHODS:
raise HTTPMethodNotAllowed(self.request.method, self.allowed_methods)
if method not in METHODS:
raise HTTPMethodNotAllowed(method, self.allowed_methods)
if not (handler := self.handlers.get(method)):
if not (handler := self.handlers.get(self.request.method)):
raise HTTPMethodNotAllowed(self.request.method, self.allowed_methods) from None
return handler(self.request, **self.request.match_info).__await__()

View file

@ -18,7 +18,7 @@ from .processors import run_processor
if typing.TYPE_CHECKING:
from aiohttp.web import Request
from aputils.signer import Signer
from typing import Callable
from collections.abc import Callable
VIEWS = []

View file

@ -29,10 +29,7 @@ install_requires = file: requirements.txt
python_requires = >=3.8
[options.extras_require]
dev =
flake8 == 3.1.0
pyinstaller == 6.3.0
pylint == 3.0
dev = file: dev-requirements.txt
[options.package_data]
relay =
@ -44,7 +41,4 @@ console_scripts =
[flake8]
extend-ignore = ANN101,ANN204,E128,E251,E261,E266,E301,E303,W191
extend-exclude = docs, test*.py
max-line-length = 100
indent-size = 4
select = F401