trustcord-sdk 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,233 @@
1
+ Metadata-Version: 2.4
2
+ Name: trustcord-sdk
3
+ Version: 1.0.0
4
+ Summary: Client and discord.py helpers for the Trustcord register
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: httpx>=0.27
9
+ Provides-Extra: discord
10
+ Requires-Dist: discord.py>=2.4; extra == "discord"
11
+ Provides-Extra: dev
12
+ Requires-Dist: discord.py>=2.4; extra == "dev"
13
+ Requires-Dist: pytest>=8.3; extra == "dev"
14
+ Requires-Dist: pytest-asyncio>=0.25; extra == "dev"
15
+ Requires-Dist: ruff>=0.8; extra == "dev"
16
+
17
+ # trustcord-sdk
18
+
19
+ A Python client for the Trustcord register, and the discord.py helpers that go
20
+ with it.
21
+
22
+ The register answers one question: has this Discord account been reported, had
23
+ that report examined by a jury, and upheld? It also exposes the public role of
24
+ active jurors and administrators, including accounts that are not listed. There is
25
+ no endpoint that returns a reason, a piece of evidence, a username or a report
26
+ count, so there is no method here that returns one either.
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ pip install trustcord-sdk # the HTTP client alone
32
+ pip install 'trustcord-sdk[discord]' # plus the discord.py helpers
33
+ ```
34
+
35
+ Python 3.11 or newer.
36
+
37
+ ## Getting a key
38
+
39
+ Access is not open. Apply on the `/developers` page of the instance you want
40
+ to use: you describe your bot, you say why a register lookup belongs in it, and
41
+ an administrator reads the application before a key is issued.
42
+
43
+ A key identifies an application, never a person, and it must stay server side.
44
+ Anything that reaches the users of your bot can spend your whole quota.
45
+
46
+ ## The client
47
+
48
+ ```python
49
+ import asyncio
50
+ from trustcord import Level, Trustcord
51
+
52
+ async def main():
53
+ async with Trustcord("or_live_…", base_url="https://api.example") as client:
54
+ listing = await client.lookup(123456789012345678)
55
+ if listing and listing.level >= Level.MEDIUM:
56
+ print(f"listed {listing.level} since {listing.since:%Y-%m-%d}")
57
+
58
+ asyncio.run(main())
59
+ ```
60
+
61
+ `base_url` can come from the `TRUSTCORD_API_URL` environment variable instead
62
+ of the argument. If neither is set, it defaults to `https://api.trustcord.org`.
63
+
64
+ `lookup` returns `None` for an account that is not listed. It also returns
65
+ `None` for an account whose report is still being deliberated, and that is
66
+ deliberate: an open report is an accusation nobody has examined yet, and the API
67
+ makes it indistinguishable from an account nobody ever named. Nothing here lets
68
+ you sanction on suspicion.
69
+
70
+ ### Public staff roles
71
+
72
+ ```python
73
+ from trustcord import StaffRole
74
+
75
+ status = await client.lookup_status(123456789012345678)
76
+ if status.staff_role is StaffRole.JURY:
77
+ print("Active Trustcord juror")
78
+ listing = status.listed # Listing or None, as with lookup().
79
+ ```
80
+
81
+ `Status` exposes `user_id`, `listed` and `staff_role`. The role is `StaffRole.JURY`,
82
+ `StaffRole.ADMIN` or `None`; it does not imply that an NDA has been signed.
83
+ `Listing.staff_role` also exposes the role when an account is listed, including
84
+ results from `lookup_many`. Existing `lookup` and `lookup_many` return types are
85
+ unchanged. Missing or null role fields from older servers become `None`.
86
+
87
+ ### A whole member list at once
88
+
89
+ ```python
90
+ answers = await client.lookup_many(member.id for member in guild.members)
91
+ listed = {user_id: l for user_id, l in answers.items() if l is not None}
92
+ ```
93
+
94
+ One call against the per-minute quota, one call per identifier against the
95
+ daily quota. Lists longer than the API's ceiling of 100 are split here rather
96
+ than refused.
97
+
98
+ ### Filing a report
99
+
100
+ ```python
101
+ reference = await client.report(
102
+ [123456789012345678],
103
+ "What happened, in at least 30 characters.",
104
+ [("capture.png", screenshot_bytes)],
105
+ )
106
+ ```
107
+
108
+ At least one screenshot is required: the jury judges the conversation, not a
109
+ summary of it. Each file's real type is checked against its bytes, so naming a
110
+ text file `capture.png` fails.
111
+
112
+ Your key is spent for the quota and nothing else. No column on the report points
113
+ back to your application, which also means you cannot read the report back or
114
+ learn what became of it. The reference is the only thread, and it belongs to
115
+ whoever filed.
116
+
117
+ ### Errors
118
+
119
+ | Exception | When |
120
+ | --- | --- |
121
+ | `Unauthorized` | key missing, invalid, revoked, application suspended (401, 403) |
122
+ | `RateLimited` | quota spent; `retry_after` is the wait in seconds (429) |
123
+ | `TrustcordError` | anything else the API refused; `status` and `message` |
124
+
125
+ ## The discord.py helpers
126
+
127
+ Article 14.1 of the GDPR puts the duty to inform on the bot, not on Trustcord.
128
+ If you restrict someone because the register says so, you must tell them, on the
129
+ server concerned, at the latest when they first interact, and point them at the
130
+ notice page. So the call that screens a member is the same call that informs
131
+ them:
132
+
133
+ ```python
134
+ from trustcord import Level, Trustcord
135
+ from trustcord.discord_ext import Listed, Registry, not_listed
136
+
137
+ client = Trustcord("or_live_…")
138
+ registry = Registry(client, notice_url="https://trustcord.example/notice")
139
+
140
+ @bot.event
141
+ async def on_member_join(member):
142
+ listing = await registry.screen(member) # informs if listed
143
+ if listing and listing.level is Level.HIGH:
144
+ await member.ban(reason=f"Trustcord {listing.level}")
145
+ ```
146
+
147
+ | Method | Looks up | Informs |
148
+ | --- | --- | --- |
149
+ | `screen(member, channel=None)` | one account | yes, if listed |
150
+ | `lookup(member_or_id)` | one account | no |
151
+ | `audit(guild)` | every member, batched | no |
152
+ | `inform(member, listing, channel=None)` | nothing | yes |
153
+
154
+ `lookup` and `audit` inform nobody because they restrict nobody. The notice is
155
+ owed when you act on the answer, not when you read it.
156
+
157
+ `inform` sends a direct message first, since the notice is addressed to the
158
+ person and not to the server, and falls back to `channel` when their DMs are
159
+ closed. Everyone in that channel reads the notice, so pass one only if it is
160
+ private to that person. The `not_listed` check never uses the command's
161
+ channel: for a slash command it answers ephemerally instead, and for a prefix
162
+ command it has no fallback. It returns `False` when neither worked: that means nobody was informed,
163
+ and you have to inform them another way before you restrict them. It sends once
164
+ per member and per guild.
165
+
166
+ ### Blocking a command
167
+
168
+ ```python
169
+ @bot.tree.command()
170
+ @not_listed(registry, minimum=Level.MEDIUM)
171
+ async def giveaway(interaction): ...
172
+ ```
173
+
174
+ `not_listed` works on prefix commands and application commands alike. It raises
175
+ `Listed`, which inherits from both check-failure hierarchies, so one
176
+ `except Listed` in an error handler covers both:
177
+
178
+ ```python
179
+ @bot.tree.error
180
+ async def on_error(interaction, error):
181
+ if isinstance(error, Listed):
182
+ await interaction.response.send_message("…", ephemeral=True)
183
+ ```
184
+
185
+ The check informs the caller before it blocks them. It fails open: if the
186
+ register is unreachable, the command runs. A moderation aid that takes the bot
187
+ down with it is worse than one that misses a listing for a minute.
188
+
189
+ `minimum` is the level from which the command is refused, so
190
+ `minimum=Level.HIGH` lets a `low` listing through untouched, and informs nobody
191
+ because nothing was restricted.
192
+
193
+ ### The notice text
194
+
195
+ `DEFAULT_NOTICE` is in French, the language of the service and of the people it
196
+ lists. Override it with `notice=` if your bot speaks another language, but keep
197
+ what the article requires: who holds the register, what it holds, where it came
198
+ from, and where to object. Three fields are interpolated: `{level}`, `{since}`
199
+ and `{notice_url}`.
200
+
201
+ ```python
202
+ registry = Registry(client, notice_url=…, notice=MY_NOTICE)
203
+ ```
204
+
205
+ ## What stays your responsibility
206
+
207
+ The SDK screens and informs. Whether to act on a listing, and how hard, is your
208
+ decision: for what your bot does with the answer, you are the controller, and
209
+ the person you restricted comes to you, not to Trustcord. A `high` listing
210
+ means a jury thought the facts might be criminal; it is not a conviction.
211
+
212
+ ## Levels
213
+
214
+ | Level | What the jury found |
215
+ | --- | --- |
216
+ | `Level.LOW` | a lapse, nothing grave |
217
+ | `Level.MEDIUM` | conduct Discord itself forbids |
218
+ | `Level.HIGH` | facts that may be criminal; reported to Pharos |
219
+
220
+ They compare by gravity, so `listing.level >= Level.MEDIUM` reads the way it
221
+ sounds. A plain string comparison would sort `high` below `low`.
222
+
223
+ ## Development
224
+
225
+ ```sh
226
+ python -m venv .venv
227
+ .venv/bin/pip install -e '.[dev]'
228
+ .venv/bin/python -m pytest -q
229
+ .venv/bin/ruff check .
230
+ ```
231
+
232
+ The tests use `httpx.MockTransport` for the client and stand-in objects for
233
+ discord.py, so nothing reaches the network.
@@ -0,0 +1,217 @@
1
+ # trustcord-sdk
2
+
3
+ A Python client for the Trustcord register, and the discord.py helpers that go
4
+ with it.
5
+
6
+ The register answers one question: has this Discord account been reported, had
7
+ that report examined by a jury, and upheld? It also exposes the public role of
8
+ active jurors and administrators, including accounts that are not listed. There is
9
+ no endpoint that returns a reason, a piece of evidence, a username or a report
10
+ count, so there is no method here that returns one either.
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ pip install trustcord-sdk # the HTTP client alone
16
+ pip install 'trustcord-sdk[discord]' # plus the discord.py helpers
17
+ ```
18
+
19
+ Python 3.11 or newer.
20
+
21
+ ## Getting a key
22
+
23
+ Access is not open. Apply on the `/developers` page of the instance you want
24
+ to use: you describe your bot, you say why a register lookup belongs in it, and
25
+ an administrator reads the application before a key is issued.
26
+
27
+ A key identifies an application, never a person, and it must stay server side.
28
+ Anything that reaches the users of your bot can spend your whole quota.
29
+
30
+ ## The client
31
+
32
+ ```python
33
+ import asyncio
34
+ from trustcord import Level, Trustcord
35
+
36
+ async def main():
37
+ async with Trustcord("or_live_…", base_url="https://api.example") as client:
38
+ listing = await client.lookup(123456789012345678)
39
+ if listing and listing.level >= Level.MEDIUM:
40
+ print(f"listed {listing.level} since {listing.since:%Y-%m-%d}")
41
+
42
+ asyncio.run(main())
43
+ ```
44
+
45
+ `base_url` can come from the `TRUSTCORD_API_URL` environment variable instead
46
+ of the argument. If neither is set, it defaults to `https://api.trustcord.org`.
47
+
48
+ `lookup` returns `None` for an account that is not listed. It also returns
49
+ `None` for an account whose report is still being deliberated, and that is
50
+ deliberate: an open report is an accusation nobody has examined yet, and the API
51
+ makes it indistinguishable from an account nobody ever named. Nothing here lets
52
+ you sanction on suspicion.
53
+
54
+ ### Public staff roles
55
+
56
+ ```python
57
+ from trustcord import StaffRole
58
+
59
+ status = await client.lookup_status(123456789012345678)
60
+ if status.staff_role is StaffRole.JURY:
61
+ print("Active Trustcord juror")
62
+ listing = status.listed # Listing or None, as with lookup().
63
+ ```
64
+
65
+ `Status` exposes `user_id`, `listed` and `staff_role`. The role is `StaffRole.JURY`,
66
+ `StaffRole.ADMIN` or `None`; it does not imply that an NDA has been signed.
67
+ `Listing.staff_role` also exposes the role when an account is listed, including
68
+ results from `lookup_many`. Existing `lookup` and `lookup_many` return types are
69
+ unchanged. Missing or null role fields from older servers become `None`.
70
+
71
+ ### A whole member list at once
72
+
73
+ ```python
74
+ answers = await client.lookup_many(member.id for member in guild.members)
75
+ listed = {user_id: l for user_id, l in answers.items() if l is not None}
76
+ ```
77
+
78
+ One call against the per-minute quota, one call per identifier against the
79
+ daily quota. Lists longer than the API's ceiling of 100 are split here rather
80
+ than refused.
81
+
82
+ ### Filing a report
83
+
84
+ ```python
85
+ reference = await client.report(
86
+ [123456789012345678],
87
+ "What happened, in at least 30 characters.",
88
+ [("capture.png", screenshot_bytes)],
89
+ )
90
+ ```
91
+
92
+ At least one screenshot is required: the jury judges the conversation, not a
93
+ summary of it. Each file's real type is checked against its bytes, so naming a
94
+ text file `capture.png` fails.
95
+
96
+ Your key is spent for the quota and nothing else. No column on the report points
97
+ back to your application, which also means you cannot read the report back or
98
+ learn what became of it. The reference is the only thread, and it belongs to
99
+ whoever filed.
100
+
101
+ ### Errors
102
+
103
+ | Exception | When |
104
+ | --- | --- |
105
+ | `Unauthorized` | key missing, invalid, revoked, application suspended (401, 403) |
106
+ | `RateLimited` | quota spent; `retry_after` is the wait in seconds (429) |
107
+ | `TrustcordError` | anything else the API refused; `status` and `message` |
108
+
109
+ ## The discord.py helpers
110
+
111
+ Article 14.1 of the GDPR puts the duty to inform on the bot, not on Trustcord.
112
+ If you restrict someone because the register says so, you must tell them, on the
113
+ server concerned, at the latest when they first interact, and point them at the
114
+ notice page. So the call that screens a member is the same call that informs
115
+ them:
116
+
117
+ ```python
118
+ from trustcord import Level, Trustcord
119
+ from trustcord.discord_ext import Listed, Registry, not_listed
120
+
121
+ client = Trustcord("or_live_…")
122
+ registry = Registry(client, notice_url="https://trustcord.example/notice")
123
+
124
+ @bot.event
125
+ async def on_member_join(member):
126
+ listing = await registry.screen(member) # informs if listed
127
+ if listing and listing.level is Level.HIGH:
128
+ await member.ban(reason=f"Trustcord {listing.level}")
129
+ ```
130
+
131
+ | Method | Looks up | Informs |
132
+ | --- | --- | --- |
133
+ | `screen(member, channel=None)` | one account | yes, if listed |
134
+ | `lookup(member_or_id)` | one account | no |
135
+ | `audit(guild)` | every member, batched | no |
136
+ | `inform(member, listing, channel=None)` | nothing | yes |
137
+
138
+ `lookup` and `audit` inform nobody because they restrict nobody. The notice is
139
+ owed when you act on the answer, not when you read it.
140
+
141
+ `inform` sends a direct message first, since the notice is addressed to the
142
+ person and not to the server, and falls back to `channel` when their DMs are
143
+ closed. Everyone in that channel reads the notice, so pass one only if it is
144
+ private to that person. The `not_listed` check never uses the command's
145
+ channel: for a slash command it answers ephemerally instead, and for a prefix
146
+ command it has no fallback. It returns `False` when neither worked: that means nobody was informed,
147
+ and you have to inform them another way before you restrict them. It sends once
148
+ per member and per guild.
149
+
150
+ ### Blocking a command
151
+
152
+ ```python
153
+ @bot.tree.command()
154
+ @not_listed(registry, minimum=Level.MEDIUM)
155
+ async def giveaway(interaction): ...
156
+ ```
157
+
158
+ `not_listed` works on prefix commands and application commands alike. It raises
159
+ `Listed`, which inherits from both check-failure hierarchies, so one
160
+ `except Listed` in an error handler covers both:
161
+
162
+ ```python
163
+ @bot.tree.error
164
+ async def on_error(interaction, error):
165
+ if isinstance(error, Listed):
166
+ await interaction.response.send_message("…", ephemeral=True)
167
+ ```
168
+
169
+ The check informs the caller before it blocks them. It fails open: if the
170
+ register is unreachable, the command runs. A moderation aid that takes the bot
171
+ down with it is worse than one that misses a listing for a minute.
172
+
173
+ `minimum` is the level from which the command is refused, so
174
+ `minimum=Level.HIGH` lets a `low` listing through untouched, and informs nobody
175
+ because nothing was restricted.
176
+
177
+ ### The notice text
178
+
179
+ `DEFAULT_NOTICE` is in French, the language of the service and of the people it
180
+ lists. Override it with `notice=` if your bot speaks another language, but keep
181
+ what the article requires: who holds the register, what it holds, where it came
182
+ from, and where to object. Three fields are interpolated: `{level}`, `{since}`
183
+ and `{notice_url}`.
184
+
185
+ ```python
186
+ registry = Registry(client, notice_url=…, notice=MY_NOTICE)
187
+ ```
188
+
189
+ ## What stays your responsibility
190
+
191
+ The SDK screens and informs. Whether to act on a listing, and how hard, is your
192
+ decision: for what your bot does with the answer, you are the controller, and
193
+ the person you restricted comes to you, not to Trustcord. A `high` listing
194
+ means a jury thought the facts might be criminal; it is not a conviction.
195
+
196
+ ## Levels
197
+
198
+ | Level | What the jury found |
199
+ | --- | --- |
200
+ | `Level.LOW` | a lapse, nothing grave |
201
+ | `Level.MEDIUM` | conduct Discord itself forbids |
202
+ | `Level.HIGH` | facts that may be criminal; reported to Pharos |
203
+
204
+ They compare by gravity, so `listing.level >= Level.MEDIUM` reads the way it
205
+ sounds. A plain string comparison would sort `high` below `low`.
206
+
207
+ ## Development
208
+
209
+ ```sh
210
+ python -m venv .venv
211
+ .venv/bin/pip install -e '.[dev]'
212
+ .venv/bin/python -m pytest -q
213
+ .venv/bin/ruff check .
214
+ ```
215
+
216
+ The tests use `httpx.MockTransport` for the client and stand-in objects for
217
+ discord.py, so nothing reaches the network.
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "trustcord-sdk"
3
+ version = "1.0.0"
4
+ description = "Client and discord.py helpers for the Trustcord register"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11"
8
+ dependencies = [
9
+ "httpx>=0.27",
10
+ ]
11
+
12
+ [project.optional-dependencies]
13
+ discord = [
14
+ "discord.py>=2.4",
15
+ ]
16
+ dev = [
17
+ "discord.py>=2.4",
18
+ "pytest>=8.3",
19
+ "pytest-asyncio>=0.25",
20
+ "ruff>=0.8",
21
+ ]
22
+
23
+ [build-system]
24
+ requires = ["setuptools>=75"]
25
+ build-backend = "setuptools.build_meta"
26
+
27
+ [tool.setuptools]
28
+ packages = ["trustcord"]
29
+
30
+ [tool.ruff]
31
+ line-length = 100
32
+ target-version = "py311"
33
+ indent-width = 4
34
+
35
+ [tool.ruff.lint]
36
+ select = ["E", "F", "I", "UP", "B", "SIM", "ASYNC"]
37
+
38
+ [tool.ruff.format]
39
+ indent-style = "space"
40
+
41
+ [tool.pytest.ini_options]
42
+ asyncio_mode = "auto"
43
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,187 @@
1
+ """What the client promises, checked against a fake API.
2
+
3
+ The register's whole contract is three fields, so most of these tests are about
4
+ what the client refuses to invent from them: no listing for an account that is
5
+ not listed, no listing for one whose case is still open, and no silent
6
+ truncation when a caller asks about more accounts than the bulk endpoint takes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import UTC, datetime
12
+
13
+ import httpx
14
+ import pytest
15
+
16
+ from trustcord import (
17
+ BULK_MAX,
18
+ Level,
19
+ RateLimited,
20
+ Trustcord,
21
+ TrustcordError,
22
+ Unauthorized,
23
+ )
24
+
25
+ BASE = "https://register.test"
26
+
27
+
28
+ def client(handler) -> Trustcord:
29
+ return Trustcord("key", base_url=BASE, transport=httpx.MockTransport(handler))
30
+
31
+
32
+ async def test_a_listed_account_comes_back_whole() -> None:
33
+ def handler(request: httpx.Request) -> httpx.Response:
34
+ assert request.headers["Authorization"] == "Bearer key"
35
+ return httpx.Response(
36
+ 200,
37
+ json={
38
+ "blacklisted": True,
39
+ "blacklistdate": 1_758_374_400,
40
+ "blacklistlevel": "medium",
41
+ },
42
+ )
43
+
44
+ async with client(handler) as api:
45
+ listing = await api.lookup(123456789012345678)
46
+
47
+ assert listing is not None
48
+ assert listing.user_id == "123456789012345678"
49
+ assert listing.level is Level.MEDIUM
50
+ assert listing.since == datetime(2025, 9, 20, 13, 20, tzinfo=UTC)
51
+
52
+
53
+ async def test_an_unlisted_account_is_none_not_an_empty_listing() -> None:
54
+ def handler(request: httpx.Request) -> httpx.Response:
55
+ return httpx.Response(200, json={"blacklisted": False})
56
+
57
+ async with client(handler) as api:
58
+ assert await api.lookup(123456789012345678) is None
59
+
60
+
61
+ async def test_an_account_under_deliberation_reads_as_unlisted() -> None:
62
+ """The API answers the same for an open case as for an unknown account."""
63
+
64
+ def handler(request: httpx.Request) -> httpx.Response:
65
+ return httpx.Response(
66
+ 200,
67
+ json={"blacklisted": False, "blacklistdate": None, "blacklistlevel": None},
68
+ )
69
+
70
+ async with client(handler) as api:
71
+ assert await api.lookup(123456789012345678) is None
72
+
73
+
74
+ async def test_levels_compare_by_gravity_not_alphabetically() -> None:
75
+ assert Level.HIGH > Level.MEDIUM > Level.LOW
76
+ assert Level.LOW < Level.HIGH
77
+ assert not Level.LOW >= Level.MEDIUM
78
+
79
+
80
+ async def test_a_long_member_list_is_split_instead_of_refused() -> None:
81
+ chunks: list[int] = []
82
+
83
+ def handler(request: httpx.Request) -> httpx.Response:
84
+ import json
85
+
86
+ ids = json.loads(request.content)["ids"]
87
+ chunks.append(len(ids))
88
+ return httpx.Response(
89
+ 200,
90
+ json={user_id: {"blacklisted": False} for user_id in ids},
91
+ )
92
+
93
+ async with client(handler) as api:
94
+ answers = await api.lookup_many(range(BULK_MAX + 5))
95
+
96
+ assert chunks == [BULK_MAX, 5]
97
+ assert len(answers) == BULK_MAX + 5
98
+ assert all(listing is None for listing in answers.values())
99
+
100
+
101
+ async def test_bulk_keeps_the_listings_it_is_given() -> None:
102
+ def handler(request: httpx.Request) -> httpx.Response:
103
+ return httpx.Response(
104
+ 200,
105
+ json={
106
+ "1": {"blacklisted": True, "blacklistdate": None, "blacklistlevel": "high"},
107
+ "2": {"blacklisted": False},
108
+ },
109
+ )
110
+
111
+ async with client(handler) as api:
112
+ answers = await api.lookup_many([1, 2])
113
+
114
+ assert answers["1"] is not None
115
+ assert answers["1"].level is Level.HIGH
116
+ assert answers["1"].since is None
117
+ assert answers["2"] is None
118
+
119
+
120
+ async def test_filing_a_report_sends_the_reason_the_ids_and_the_files() -> None:
121
+ seen: dict[str, bytes] = {}
122
+
123
+ def handler(request: httpx.Request) -> httpx.Response:
124
+ seen["body"] = request.content
125
+ return httpx.Response(201, json={"reference": "OR-2025-0001", "targets": 1})
126
+
127
+ async with client(handler) as api:
128
+ reference = await api.report(
129
+ [123456789012345678],
130
+ "a reason long enough for the API to accept it",
131
+ [("capture.png", b"\x89PNG\r\n\x1a\n")],
132
+ )
133
+
134
+ assert reference == "OR-2025-0001"
135
+ body = seen["body"]
136
+ assert b"a reason long enough" in body
137
+ assert b"123456789012345678" in body
138
+ assert b"capture.png" in body
139
+
140
+
141
+ async def test_a_spent_quota_raises_with_the_wait_the_api_asked_for() -> None:
142
+ def handler(request: httpx.Request) -> httpx.Response:
143
+ return httpx.Response(
144
+ 429, json={"detail": "quota dépassé"}, headers={"Retry-After": "42"}
145
+ )
146
+
147
+ async with client(handler) as api:
148
+ with pytest.raises(RateLimited) as caught:
149
+ await api.lookup(123456789012345678)
150
+
151
+ assert caught.value.retry_after == 42
152
+ assert caught.value.status == 429
153
+
154
+
155
+ async def test_a_revoked_key_raises_unauthorized() -> None:
156
+ def handler(request: httpx.Request) -> httpx.Response:
157
+ return httpx.Response(401, json={"detail": "clé révoquée"})
158
+
159
+ async with client(handler) as api:
160
+ with pytest.raises(Unauthorized):
161
+ await api.lookup(123456789012345678)
162
+
163
+
164
+ async def test_an_unparseable_error_still_carries_its_status() -> None:
165
+ def handler(request: httpx.Request) -> httpx.Response:
166
+ return httpx.Response(500, text="<html>gateway</html>")
167
+
168
+ async with client(handler) as api:
169
+ with pytest.raises(TrustcordError) as caught:
170
+ await api.lookup(123456789012345678)
171
+
172
+ assert caught.value.status == 500
173
+
174
+
175
+ async def test_the_base_url_defaults_to_trustcord(monkeypatch) -> None:
176
+ monkeypatch.delenv("TRUSTCORD_API_URL", raising=False)
177
+ api = Trustcord("key")
178
+ assert str(api._http.base_url) == "https://api.trustcord.org"
179
+ assert api._http.headers["User-Agent"] == "trustcord-sdk/1.0"
180
+ await api.close()
181
+
182
+
183
+ async def test_the_base_url_can_come_from_the_environment(monkeypatch) -> None:
184
+ monkeypatch.setenv("TRUSTCORD_API_URL", BASE)
185
+ api = Trustcord("key")
186
+ assert str(api._http.base_url) == BASE
187
+ await api.close()