Skip to content

Commit 8049455

Browse files
committed
merge main
2 parents 78db9c7 + ac8667a commit 8049455

9 files changed

Lines changed: 259 additions & 114 deletions

File tree

.github/workflows/check.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: Prek Auto Fix
2+
3+
on:
4+
pull_request:
5+
branches: [main,stable]
6+
7+
permissions:
8+
contents: write
9+
10+
jobs:
11+
prek-fix:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
with:
16+
ref: ${{ github.head_ref }}
17+
fetch-depth: 0
18+
19+
- name: Install uv
20+
uses: astral-sh/setup-uv@v5
21+
22+
- name: Check lock file consistency
23+
run: uv sync --locked --all-extras --all-groups
24+
25+
- name: Run prek on PR changes
26+
run: |
27+
uv run prek run --origin origin/${{ github.base_ref }} --source HEAD || true
28+
29+
- name: Auto Commit Fixes
30+
uses: stefanzweifel/git-auto-commit-action@v7
31+
with:
32+
commit_message: "chore: auto fixes by prek"

.pre-commit-config.yaml

Lines changed: 22 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,23 @@
11
repos:
2-
- hooks:
3-
- additional_dependencies:
4-
- aiohttp>=3.12.15
5-
- apmodel>=0.5.1
6-
- apsig>=0.6.0
7-
- charset-normalizer>=3.4.3
8-
- coverage>=7.10.7
9-
- fastapi>=0.116.1
10-
- httpcore[http2,socks]>=1.0.9
11-
- httpx>=0.28.1
12-
- pytest-cov>=7.0.0
13-
- pytest>=8.4.1
14-
- redis>=5.0.4
15-
- requests>=2.32.5
16-
- types-requests>=2.32.4.20250913
17-
- uvicorn>=0.35.0
18-
args:
19-
- --fix
20-
description: Run 'ruff' for extremely fast Python linting
21-
entry: ruff check --force-exclude
22-
id: ruff
23-
language: python
24-
minimum_pre_commit_version: 2.9.2
25-
name: ruff
26-
require_serial: true
27-
types_or:
28-
- python
29-
- pyi
30-
- additional_dependencies:
31-
- aiohttp>=3.12.15
32-
- apmodel>=0.5.1
33-
- apsig>=0.6.0
34-
- charset-normalizer>=3.4.3
35-
- coverage>=7.10.7
36-
- fastapi>=0.116.1
37-
- httpcore[http2,socks]>=1.0.9
38-
- httpx>=0.28.1
39-
- pytest-cov>=7.0.0
40-
- pytest>=8.4.1
41-
- redis>=5.0.4
42-
- requests>=2.32.5
43-
- types-requests>=2.32.4.20250913
44-
- uvicorn>=0.35.0
45-
args: []
46-
description: Run 'ruff format' for extremely fast Python formatting
47-
entry: ruff format --force-exclude
48-
id: ruff-format
49-
language: python
50-
minimum_pre_commit_version: 2.9.2
51-
name: ruff-format
52-
require_serial: true
53-
types_or:
54-
- python
55-
- pyi
56-
repo: https://github.com/astral-sh/ruff-pre-commit
57-
rev: v0.15.1
58-
- hooks:
59-
- additional_dependencies:
60-
- aiohttp>=3.12.15
61-
- apmodel>=0.5.1
62-
- apsig>=0.6.0
63-
- charset-normalizer>=3.4.3
64-
- coverage>=7.10.7
65-
- fastapi>=0.116.1
66-
- httpcore[http2,socks]>=1.0.9
67-
- httpx>=0.28.1
68-
- pytest-cov>=7.0.0
69-
- pytest>=8.4.1
70-
- redis>=5.0.4
71-
- requests>=2.32.5
72-
- types-requests>=2.32.4.20250913
73-
- uvicorn>=0.35.0
74-
id: pyrefly-check
75-
name: Pyrefly (type checking)
76-
pass_filenames: false
77-
repo: https://github.com/facebook/pyrefly-pre-commit
78-
rev: 0.52.0
2+
- repo: local
3+
hooks:
4+
- id: ruff
5+
name: ruff
6+
entry: ruff check --force-exclude --fix
7+
language: system
8+
types_or: [python, pyi]
9+
require_serial: true
10+
11+
- id: ruff-format
12+
name: ruff-format
13+
entry: ruff format --force-exclude
14+
language: system
15+
types_or: [python, pyi]
16+
require_serial: true
17+
18+
- id: pyrefly-check
19+
name: Pyrefly (type checking)
20+
entry: pyrefly check
21+
language: system
22+
pass_filenames: false
23+
always_run: true

docs/guides/outbox.md

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# Using Outbox
2+
3+
[Outbox](https://www.w3.org/wiki/ActivityPub/Primer/Outbox) is **required** by the specification, but most implementations recognize an Actor even if it doesn't have an Outbox.
4+
5+
This guide explains how to implement a simple Outbox.
6+
7+
## Define Empty Outbox
8+
9+
First, you must define the Outbox in order to use it:
10+
11+
```python
12+
app.outbox("/users/{identifier}/outbox")
13+
```
14+
15+
The Outbox is handled by subscribing to the special `Outbox` class from `apkit.types` using the `app.on` decorator:
16+
17+
```python
18+
from apkit.models import OrderedCollection, Person
19+
from apkit.server.types import Context
20+
from apkit.server.responses import ActivityResponse
21+
22+
...
23+
24+
person = Person(
25+
...
26+
outbox="https://example.com/users/1/outbox"
27+
)
28+
29+
@app.on(Outbox)
30+
async def listen_outbox(ctx: Context):
31+
identifier = ctx.request.path_params.get("identifier")
32+
col = OrderedCollection(
33+
id=f"https://example.com/users/{identifier}/outbox",
34+
total_items=0,
35+
ordered_items=[]
36+
)
37+
return ActivityResponse(col)
38+
```
39+
40+
## Returning real data
41+
In most cases, you don't actually need to return the contents. (However, some implementations use the contents of the outbox to count the number of posts.)
42+
43+
However, this time let us retrieve and return actual data.
44+
45+
```python
46+
from datetime import datetime
47+
48+
from apkit.models import Announce, Create, Delete, Note, Tombstone, Person, OrderedCollection, OrderedCollectionPage
49+
from fastapi.responses import JSONResponse
50+
51+
person = Person(id="https://example.com/users/alice")
52+
53+
PAGE_SIZE = 20
54+
posts = [
55+
Announce(
56+
id="https://example.com/users/alice/activities/4",
57+
actor=person.id,
58+
published=datetime(2026, 1, 3),
59+
to=["https://www.w3.org/ns/activitystreams#Public"],
60+
object="https://example.net/users/bob/notes/2",
61+
),
62+
Delete(
63+
id="https://example.com/users/alice/activities/3",
64+
actor=person.id,
65+
published=datetime(2026, 1, 2),
66+
to=["https://www.w3.org/ns/activitystreams#Public"],
67+
object=Tombstone(
68+
id="https://example.com/users/alice/notes/2",
69+
),
70+
),
71+
Create(
72+
id="https://example.com/users/alice/activities/1",
73+
actor=person.id,
74+
published=datetime(2026, 1, 1),
75+
to=["https://www.w3.org/ns/activitystreams#Public"],
76+
object=Note(
77+
id="https://example.com/users/alice/notes/1",
78+
attributedTo="https://example.com/users/alice",
79+
content="<p>Hello World!</p>",
80+
published=datetime(2026, 1, 1),
81+
to=["https://www.w3.org/ns/activitystreams#Public"]
82+
),
83+
)
84+
]
85+
86+
@app.on(Outbox)
87+
async def listen_outbox(ctx: Context):
88+
identifier = ctx.request.path_params.get("identifier")
89+
if identifier != "alice":
90+
return JSONResponse({"message": "Not Found"}, status_code=404)
91+
outbox_url = f"https://example.com/users/{identifier}/outbox"
92+
93+
is_page = ctx.request.query_params.get("page") == "true"
94+
max_id = ctx.request.query_params.get("max_id")
95+
96+
if not is_page:
97+
col = OrderedCollection(
98+
id=outbox_url,
99+
total_items=len(posts),
100+
first=f"{outbox_url}?page=true",
101+
last=f"{outbox_url}?page=true&min_id={posts[-1].id}" if posts else None
102+
)
103+
return ActivityResponse(col)
104+
105+
start_index = 0
106+
if max_id:
107+
for i, p in enumerate(posts):
108+
if p.id == max_id:
109+
start_index = i + 1
110+
break
111+
112+
page_items = posts[start_index : start_index + PAGE_SIZE]
113+
114+
next_url = None
115+
if start_index + PAGE_SIZE < len(posts):
116+
last_item_id = page_items[-1].id
117+
next_url = f"{outbox_url}?page=true&max_id={last_item_id}"
118+
119+
page = OrderedCollectionPage(
120+
id=f"{outbox_url}?page=true" + (f"&max_id={max_id}" if max_id else ""),
121+
part_of=outbox_url,
122+
ordered_items=page_items,
123+
next=next_url
124+
)
125+
return ActivityResponse(page)
126+
127+
```
128+
129+
!!! tips "What is `Tombstone`?"
130+
131+
The `Tombstone` type indicates content that existed in the past but has now been deleted. By returning this object instead of completely removing the item from the Outbox, you can explicitly communicate to the remote server that "this post has been deleted."

examples/send_message.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import uuid
66
from datetime import UTC, datetime
77

8+
from apmodel.vocab.mention import Mention
89
from cryptography.hazmat.primitives import serialization as crypto_serialization
910
from cryptography.hazmat.primitives.asymmetric import rsa
1011

@@ -106,13 +107,19 @@ async def send_note(recepient: str) -> None:
106107
logger.info(f"Found actor's inbox: {inbox_url}")
107108

108109
# Create note
110+
t = f'<p><span class="h-card" translate="no"><a href="{target_actor.url}" class="u-url mention">@<span>{target_actor.preferred_username}</span></a></span></p>'
109111
note = Note(
110112
id=f"https://{HOST}/notes/{uuid.uuid4()}",
111113
attributed_to=actor.id,
112-
content="<p>Hello from apkit</p>",
114+
content=f"<p>{t} Hello from apkit</p>",
113115
published=datetime.now(UTC).isoformat() + "Z",
114116
to=[target_actor.id],
115117
cc=["https://www.w3.org/ns/activitystreams#Public"],
118+
tag=[
119+
Mention(
120+
href=target_actor.url, name=f"@{target_actor.preferred_username}"
121+
)
122+
],
116123
)
117124

118125
# Create activity

mkdocs.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
site_name: apkit
2-
site_url: https://fedi-libs.github.io/apkit
2+
site_url: https://apkit.fedi-libs.org
33
repo_url: https://github.com/fedi-libs/apkit
44
edit_uri: edit/main/docs/
55

@@ -11,6 +11,7 @@ nav:
1111
- Configuration: guides/configuration.md
1212
- Client: guides/client.md
1313
- Server: guides/server.md
14+
- Outbox: guides/outbox.md
1415
- Models: guides/models.md
1516
- KV Store: guides/kv_store.md
1617
- Nodeinfo: guides/nodeinfo.md

pyproject.toml

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,13 @@ dev = [
6161
"pytest-asyncio>=1.3.0",
6262
"pytest-cov>=7.0.0",
6363
"respx>=0.22.0",
64+
"pyrefly>=0.46.0",
65+
"ruff>=0.14.10",
66+
"prek>=0.3.3"
6467
]
6568
docs = [
6669
"mkdocs>=1.6.1",
67-
"mkdocs-material>=9.6.19",
68-
]
69-
lint = [
70-
"pyrefly>=0.46.0",
71-
"ruff>=0.14.10",
70+
"mkdocs-material==9.6.20",
7271
]
7372

7473
[tool.ruff]
@@ -120,12 +119,17 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
120119

121120
[tool.pyrefly]
122121
project-includes = [
123-
"src/**/*.py*",
124-
"tests/**/*.py*",
122+
"src/**/*.py",
123+
"tests/**/*.py",
125124
]
126125
project-excludes = [
127-
"scripts/**/*.py"
126+
"scripts/**/*.py",
127+
"**/.*",
128+
"**/*venv/**",
128129
]
130+
search-path = ["src"]
131+
ignore-errors-in-generated-code = true
132+
129133

130134
[tool.ruff.format]
131135
quote-style = "double"

src/apkit/_version.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
commit_id: COMMIT_ID
2929
__commit_id__: COMMIT_ID
3030

31-
__version__ = version = '0.3.3.post1.dev88+g400fc80e6.d20260209'
32-
__version_tuple__ = version_tuple = (0, 3, 3, 'post1', 'dev88', 'g400fc80e6.d20260209')
31+
__version__ = version = '0.3.8.post1.dev28+g78db9c7ea.d20260223'
32+
__version_tuple__ = version_tuple = (0, 3, 8, 'post1', 'dev28', 'g78db9c7ea.d20260223')
3333

3434
__commit_id__ = commit_id = None

src/apkit/helper/inbox.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,11 @@ async def __fetch_actor(self, activity: Activity) -> Optional[Actor]:
3535
async with ActivityPubClient() as client:
3636
match activity.actor:
3737
case str() as url:
38-
actor = await client.actor.fetch(url=url)
38+
# TODO: fix later
39+
actor = await client.actor.fetch(url=url) # pyrefly: ignore
3940
case Link(href=str() as url):
40-
actor = await client.actor.fetch(url=url)
41+
# TODO: fix later
42+
actor = await client.actor.fetch(url=url) # pyrefly: ignore
4143
case Actor() as actor_obj:
4244
actor = actor_obj
4345
case _:

0 commit comments

Comments
 (0)