ctfy.sdk.resources.me
client.me — the calling user's profile, inbox, account + progress.
1"""``client.me`` — the calling user's profile, inbox, account + progress.""" 2 3from __future__ import annotations 4 5from typing import Any 6 7from ctfy.sdk._helpers import _raise_for_status 8from ctfy.sdk.base import BaseHttpClient 9from ctfy.server.models import ( 10 AccountDeletionAccepted, 11 EmailVerificationSent, 12 InboxResponse, 13 MeResponse, 14 MilestoneProgress, 15 MyAchievementsResponse, 16 MyFavourites, 17 MyResults, 18 MySolveSummary, 19 NotificationPreferencesResponse, 20 ReauthenticationOpened, 21 StarGazerVerifyResponse, 22) 23 24 25class MeResource: 26 """Self-service surface for the authenticated caller.""" 27 28 def __init__(self, http: BaseHttpClient) -> None: 29 self._http = http 30 31 def get(self) -> MeResponse: 32 """Get the calling user's profile + auth context. 33 34 Per-competition team memberships ride along on 35 ``competition_teams``; there is no global "current team" any 36 more — pick the row whose ``competition_id`` matches the 37 scope you care about. 38 """ 39 resp = self._http.request("GET", "/me") 40 _raise_for_status(resp) 41 return MeResponse.model_validate(resp.json()) 42 43 def inbox(self) -> InboxResponse: 44 """Pending team invites, join requests, direct invites, announcements.""" 45 resp = self._http.request("GET", "/me/inbox") 46 _raise_for_status(resp) 47 return InboxResponse.model_validate(resp.json()) 48 49 def notification_preferences(self) -> NotificationPreferencesResponse: 50 """Which categories reach the caller by email. 51 52 Each cell reports ``effective`` (what happens now), ``default`` 53 (what reverting would restore) and ``explicit`` (whether the user 54 actually chose it, or is merely inheriting). Storage is sparse, so 55 an untouched cell has no row — ``explicit`` is how you tell. 56 """ 57 resp = self._http.request("GET", "/me/notification-preferences") 58 _raise_for_status(resp) 59 return NotificationPreferencesResponse.model_validate(resp.json()) 60 61 def set_notification_preference( 62 self, category: str, enabled: bool, *, channel: str = "email" 63 ) -> NotificationPreferencesResponse: 64 """Turn one category on or off for one channel. 65 66 ``security`` is refused with a 400 — sign-in codes and account 67 alerts exist to protect the account and are not switchable. 68 """ 69 resp = self._http.request( 70 "PATCH", 71 "/me/notification-preferences", 72 json={"category": category, "channel": channel, "enabled": enabled}, 73 ) 74 _raise_for_status(resp) 75 return NotificationPreferencesResponse.model_validate(resp.json()) 76 77 def clear_notification_preference( 78 self, category: str, *, channel: str = "email" 79 ) -> NotificationPreferencesResponse: 80 """Drop the explicit cell so the default applies again. 81 82 This is *revert*, not *disable*: for most categories the default 83 is on, so clearing an explicit ``False`` turns mail back on. 84 """ 85 resp = self._http.request("DELETE", f"/me/notification-preferences/{category}/{channel}") 86 _raise_for_status(resp) 87 return NotificationPreferencesResponse.model_validate(resp.json()) 88 89 def read_announcement(self, announcement_id: str) -> None: 90 """Mark one announcement as read for the calling user.""" 91 resp = self._http.request("POST", f"/me/announcements/{announcement_id}/read") 92 _raise_for_status(resp) 93 94 def read_all_announcements(self) -> None: 95 """Mark every relevant announcement as read.""" 96 resp = self._http.request("POST", "/me/announcements/read-all") 97 _raise_for_status(resp) 98 99 def update_profile(self, **fields: Any) -> MeResponse: 100 """PATCH the caller's profile. 101 102 Pass any subset of ``bio`` / ``country`` / ``website_url`` / 103 ``timezone`` / ``social_links``. Omit a key to leave it unchanged; 104 pass ``None`` to clear it. Returns the updated :class:`MeResponse`. 105 106 Example:: 107 108 client.me.update_profile(bio="hacking goblins", country="JP") 109 client.me.update_profile(website_url=None) # clear it 110 """ 111 resp = self._http.request("PATCH", "/me/profile", json=fields) 112 _raise_for_status(resp) 113 return MeResponse.model_validate(resp.json()) 114 115 def update_visibility(self, overrides: dict[str, bool]) -> MeResponse: 116 """PATCH per-field privacy flags on the caller's profile. 117 118 Keys outside :data:`PROFILE_VISIBILITY_KEYS` are silently dropped 119 server-side. Returns the updated :class:`MeResponse`. 120 """ 121 resp = self._http.request("PATCH", "/me/profile/visibility", json=overrides) 122 _raise_for_status(resp) 123 return MeResponse.model_validate(resp.json()) 124 125 def export_account(self) -> bytes: 126 """Download a ZIP archive of every piece of data the platform 127 holds about the caller (user, identities, tokens, memberships, 128 submissions, solves, activity). Returns raw bytes — write to a 129 file with ``Path(...).write_bytes(client.me.export_account())``.""" 130 resp = self._http.request("GET", "/me/export") 131 _raise_for_status(resp) 132 return resp.content 133 134 def reauthenticate( 135 self, 136 *, 137 current_password: str = "", 138 code: str = "", 139 ) -> ReauthenticationOpened: 140 """Prove a factor again, opening the sudo window on **this** token. 141 142 A handful of account changes — repointing the primary address, 143 setting or removing a password, unlinking an identity, minting a 144 token, widening one, adding an address, linking an identity, 145 approving a device login — refuse with ``409 146 reauthentication_required`` unless the credential making the 147 request proved a factor inside the last 148 ``reauth_window_s`` seconds. This is how it proves one. 149 150 Signing in already counts, so a freshly-minted session needs 151 nothing; this is for a bearer that has been held past the 152 window. Read ``deletion_proof`` off :meth:`get` to learn which 153 factor this account has — it is the same answer for both — and 154 pass that one. An account with neither passes with neither. 155 156 The window lands on the token in the ``Authorization`` header 157 and no other: proving on a laptop does not open it for a bearer 158 somebody else is holding. 159 """ 160 body: dict[str, str] = {} 161 if current_password: 162 body["current_password"] = current_password 163 if code: 164 body["code"] = code 165 resp = self._http.request("POST", "/me/reauthenticate", json=body) 166 _raise_for_status(resp) 167 return ReauthenticationOpened.model_validate(resp.json()) 168 169 def request_reauth_code(self) -> EmailVerificationSent: 170 """Mail this account's own address a code that opens the window. 171 172 Refused (409 ``reauth_proof_required``) unless a mailed code is 173 what this account re-proves with — one holding a password 174 re-enters that instead. ⚠️ A *different* code from the deletion 175 one: digits handed over under "confirm it is you" must not also 176 erase the account. 177 """ 178 resp = self._http.request("POST", "/me/reauthenticate/code") 179 _raise_for_status(resp) 180 return EmailVerificationSent.model_validate(resp.json()) 181 182 def request_deletion_code(self) -> EmailVerificationSent: 183 """Mail this account's own address a code that deletes it. 184 185 Refused (409 ``deletion_proof_required``) unless a mailed code is 186 what this account's proof actually is — read ``deletion_proof`` 187 off :meth:`get` first. An account with a password re-enters that 188 instead, and one with neither needs no code at all. 189 """ 190 resp = self._http.request("POST", "/me/deletion-code") 191 _raise_for_status(resp) 192 return EmailVerificationSent.model_validate(resp.json()) 193 194 def delete_account( 195 self, 196 confirm_display_name: str, 197 *, 198 current_password: str = "", 199 code: str = "", 200 ) -> AccountDeletionAccepted: 201 """Self-delete the calling user. 202 203 ``confirm_display_name`` must match the caller's display name 204 (falls back to email when empty) exactly — the typo gate against 205 fat-fingered destruction, and **not** the authorization half: it 206 is a public string. 207 208 The second factor is whichever ``deletion_proof`` on 209 :meth:`get` names — ``current_password`` for an account holding 210 a password, ``code`` from :meth:`request_deletion_code` for one 211 proving a mailed address, neither when it answers ``none``. The 212 server composes that answer and enforces it, so read it rather 213 than guessing; sending the wrong one is a 401. 214 215 ⚠️ Both are keyword-only and default blank, so a caller written 216 before the gate existed still compiles — and still works against 217 an account whose answer is ``none``, which is every account that 218 predates it. 219 220 ⚠️ **Read the answer: this does not always delete anything yet.** 221 With ``account_deletion_grace_s`` above zero — the default — the 222 account is *scheduled*: its tokens are revoked and a job runs the 223 cascade later, and signing in before then cancels the whole 224 thing. ``deleted`` says which happened and ``effective_at`` says 225 when it becomes final. A caller that discards this cannot tell 226 *gone* from *recoverable*, which are opposite facts with opposite 227 next steps. 228 """ 229 body: dict[str, str] = {"confirm_display_name": confirm_display_name} 230 if current_password: 231 body["current_password"] = current_password 232 if code: 233 body["code"] = code 234 resp = self._http.request("DELETE", "/me", json=body) 235 _raise_for_status(resp) 236 return AccountDeletionAccepted.model_validate(resp.json()) 237 238 def achievements(self, competition_id: str = "") -> MyAchievementsResponse: 239 """Caller's unlocked + locked badges (with progress hints).""" 240 params: dict[str, str] = {} 241 if competition_id: 242 params["competition_id"] = competition_id 243 resp = self._http.request("GET", "/me/achievements", params=params) 244 _raise_for_status(resp) 245 return MyAchievementsResponse.model_validate(resp.json()) 246 247 def results(self) -> MyResults: 248 """我的战绩: every started event the caller entered, newest first, each 249 at that event's own official placement (never a total). Includes the 250 sittings only the caller may see -- in progress, frozen (ranked as of 251 the freeze), anonymous, disqualified -- each with ``only_you`` set.""" 252 resp = self._http.request("GET", "/me/results") 253 _raise_for_status(resp) 254 return MyResults.model_validate(resp.json()) 255 256 def favourites(self) -> MyFavourites: 257 """Every competition and challenge the caller has starred (#2070) — 258 whole sets, and only ever the caller's own: a favourite is a private 259 preference, never shown to another person or shared with a team.""" 260 resp = self._http.request("GET", "/me/favourites") 261 _raise_for_status(resp) 262 return MyFavourites.model_validate(resp.json()) 263 264 def favourite(self, kind: str, target_id: str) -> MyFavourites: 265 """Star a ``competition`` or a ``challenge``; returns the caller's full 266 set. Idempotent. A competition the caller cannot see is a 404.""" 267 resp = self._http.request("PUT", f"/me/favourites/{kind}/{target_id}") 268 _raise_for_status(resp) 269 return MyFavourites.model_validate(resp.json()) 270 271 def unfavourite(self, kind: str, target_id: str) -> MyFavourites: 272 """Remove a star; removing one that is not there is the same answer.""" 273 resp = self._http.request("DELETE", f"/me/favourites/{kind}/{target_id}") 274 _raise_for_status(resp) 275 return MyFavourites.model_validate(resp.json()) 276 277 def solves(self, *, competition_id: str = "") -> list[MySolveSummary]: 278 """Per-challenge solve summary for the caller. 279 280 Unscoped this spans every team the caller has ever played for. 281 Pass ``competition_id`` to ask the narrower question a 282 competition-scoped surface means: have I solved this *here*. 283 """ 284 params: dict[str, str] = {} 285 if competition_id: 286 params["competition_id"] = competition_id 287 resp = self._http.request("GET", "/me/solves", params=params) 288 _raise_for_status(resp) 289 return [MySolveSummary.model_validate(s) for s in resp.json()] 290 291 def milestone_progress(self, *, competition_id: str = "") -> list[MilestoneProgress]: 292 """Partial-solve progress per challenge for the caller. 293 294 One row per challenge with at least one captured question; 295 ``solved_question_ids`` enumerates which milestones the user 296 has cleared. The row deliberately carries no question total: 297 the number of objectives is player-facing secret (#2435). 298 With ``competition_id`` set the result narrows to solves 299 stamped against the caller's team in that comp. 300 """ 301 params: dict[str, str] = {} 302 if competition_id: 303 params["competition_id"] = competition_id 304 resp = self._http.request("GET", "/me/milestone-progress", params=params) 305 _raise_for_status(resp) 306 return [MilestoneProgress.model_validate(s) for s in resp.json()] 307 308 def verify_star_gazer(self, competition_id: str = "") -> StarGazerVerifyResponse: 309 """Verify the caller starred the ctfy GitHub repo and grant the 310 ``star_gazer`` badge if so. 311 312 Friendly failures (no GitHub identity, repo not configured, not 313 yet starred, GitHub rate-limited) come back as 200 with 314 ``verified=False`` + an actionable ``reason``.""" 315 params = {"competition_id": competition_id} if competition_id else None 316 resp = self._http.request("POST", "/me/star-gazer/verify", params=params) 317 _raise_for_status(resp) 318 return StarGazerVerifyResponse.model_validate(resp.json())
26class MeResource: 27 """Self-service surface for the authenticated caller.""" 28 29 def __init__(self, http: BaseHttpClient) -> None: 30 self._http = http 31 32 def get(self) -> MeResponse: 33 """Get the calling user's profile + auth context. 34 35 Per-competition team memberships ride along on 36 ``competition_teams``; there is no global "current team" any 37 more — pick the row whose ``competition_id`` matches the 38 scope you care about. 39 """ 40 resp = self._http.request("GET", "/me") 41 _raise_for_status(resp) 42 return MeResponse.model_validate(resp.json()) 43 44 def inbox(self) -> InboxResponse: 45 """Pending team invites, join requests, direct invites, announcements.""" 46 resp = self._http.request("GET", "/me/inbox") 47 _raise_for_status(resp) 48 return InboxResponse.model_validate(resp.json()) 49 50 def notification_preferences(self) -> NotificationPreferencesResponse: 51 """Which categories reach the caller by email. 52 53 Each cell reports ``effective`` (what happens now), ``default`` 54 (what reverting would restore) and ``explicit`` (whether the user 55 actually chose it, or is merely inheriting). Storage is sparse, so 56 an untouched cell has no row — ``explicit`` is how you tell. 57 """ 58 resp = self._http.request("GET", "/me/notification-preferences") 59 _raise_for_status(resp) 60 return NotificationPreferencesResponse.model_validate(resp.json()) 61 62 def set_notification_preference( 63 self, category: str, enabled: bool, *, channel: str = "email" 64 ) -> NotificationPreferencesResponse: 65 """Turn one category on or off for one channel. 66 67 ``security`` is refused with a 400 — sign-in codes and account 68 alerts exist to protect the account and are not switchable. 69 """ 70 resp = self._http.request( 71 "PATCH", 72 "/me/notification-preferences", 73 json={"category": category, "channel": channel, "enabled": enabled}, 74 ) 75 _raise_for_status(resp) 76 return NotificationPreferencesResponse.model_validate(resp.json()) 77 78 def clear_notification_preference( 79 self, category: str, *, channel: str = "email" 80 ) -> NotificationPreferencesResponse: 81 """Drop the explicit cell so the default applies again. 82 83 This is *revert*, not *disable*: for most categories the default 84 is on, so clearing an explicit ``False`` turns mail back on. 85 """ 86 resp = self._http.request("DELETE", f"/me/notification-preferences/{category}/{channel}") 87 _raise_for_status(resp) 88 return NotificationPreferencesResponse.model_validate(resp.json()) 89 90 def read_announcement(self, announcement_id: str) -> None: 91 """Mark one announcement as read for the calling user.""" 92 resp = self._http.request("POST", f"/me/announcements/{announcement_id}/read") 93 _raise_for_status(resp) 94 95 def read_all_announcements(self) -> None: 96 """Mark every relevant announcement as read.""" 97 resp = self._http.request("POST", "/me/announcements/read-all") 98 _raise_for_status(resp) 99 100 def update_profile(self, **fields: Any) -> MeResponse: 101 """PATCH the caller's profile. 102 103 Pass any subset of ``bio`` / ``country`` / ``website_url`` / 104 ``timezone`` / ``social_links``. Omit a key to leave it unchanged; 105 pass ``None`` to clear it. Returns the updated :class:`MeResponse`. 106 107 Example:: 108 109 client.me.update_profile(bio="hacking goblins", country="JP") 110 client.me.update_profile(website_url=None) # clear it 111 """ 112 resp = self._http.request("PATCH", "/me/profile", json=fields) 113 _raise_for_status(resp) 114 return MeResponse.model_validate(resp.json()) 115 116 def update_visibility(self, overrides: dict[str, bool]) -> MeResponse: 117 """PATCH per-field privacy flags on the caller's profile. 118 119 Keys outside :data:`PROFILE_VISIBILITY_KEYS` are silently dropped 120 server-side. Returns the updated :class:`MeResponse`. 121 """ 122 resp = self._http.request("PATCH", "/me/profile/visibility", json=overrides) 123 _raise_for_status(resp) 124 return MeResponse.model_validate(resp.json()) 125 126 def export_account(self) -> bytes: 127 """Download a ZIP archive of every piece of data the platform 128 holds about the caller (user, identities, tokens, memberships, 129 submissions, solves, activity). Returns raw bytes — write to a 130 file with ``Path(...).write_bytes(client.me.export_account())``.""" 131 resp = self._http.request("GET", "/me/export") 132 _raise_for_status(resp) 133 return resp.content 134 135 def reauthenticate( 136 self, 137 *, 138 current_password: str = "", 139 code: str = "", 140 ) -> ReauthenticationOpened: 141 """Prove a factor again, opening the sudo window on **this** token. 142 143 A handful of account changes — repointing the primary address, 144 setting or removing a password, unlinking an identity, minting a 145 token, widening one, adding an address, linking an identity, 146 approving a device login — refuse with ``409 147 reauthentication_required`` unless the credential making the 148 request proved a factor inside the last 149 ``reauth_window_s`` seconds. This is how it proves one. 150 151 Signing in already counts, so a freshly-minted session needs 152 nothing; this is for a bearer that has been held past the 153 window. Read ``deletion_proof`` off :meth:`get` to learn which 154 factor this account has — it is the same answer for both — and 155 pass that one. An account with neither passes with neither. 156 157 The window lands on the token in the ``Authorization`` header 158 and no other: proving on a laptop does not open it for a bearer 159 somebody else is holding. 160 """ 161 body: dict[str, str] = {} 162 if current_password: 163 body["current_password"] = current_password 164 if code: 165 body["code"] = code 166 resp = self._http.request("POST", "/me/reauthenticate", json=body) 167 _raise_for_status(resp) 168 return ReauthenticationOpened.model_validate(resp.json()) 169 170 def request_reauth_code(self) -> EmailVerificationSent: 171 """Mail this account's own address a code that opens the window. 172 173 Refused (409 ``reauth_proof_required``) unless a mailed code is 174 what this account re-proves with — one holding a password 175 re-enters that instead. ⚠️ A *different* code from the deletion 176 one: digits handed over under "confirm it is you" must not also 177 erase the account. 178 """ 179 resp = self._http.request("POST", "/me/reauthenticate/code") 180 _raise_for_status(resp) 181 return EmailVerificationSent.model_validate(resp.json()) 182 183 def request_deletion_code(self) -> EmailVerificationSent: 184 """Mail this account's own address a code that deletes it. 185 186 Refused (409 ``deletion_proof_required``) unless a mailed code is 187 what this account's proof actually is — read ``deletion_proof`` 188 off :meth:`get` first. An account with a password re-enters that 189 instead, and one with neither needs no code at all. 190 """ 191 resp = self._http.request("POST", "/me/deletion-code") 192 _raise_for_status(resp) 193 return EmailVerificationSent.model_validate(resp.json()) 194 195 def delete_account( 196 self, 197 confirm_display_name: str, 198 *, 199 current_password: str = "", 200 code: str = "", 201 ) -> AccountDeletionAccepted: 202 """Self-delete the calling user. 203 204 ``confirm_display_name`` must match the caller's display name 205 (falls back to email when empty) exactly — the typo gate against 206 fat-fingered destruction, and **not** the authorization half: it 207 is a public string. 208 209 The second factor is whichever ``deletion_proof`` on 210 :meth:`get` names — ``current_password`` for an account holding 211 a password, ``code`` from :meth:`request_deletion_code` for one 212 proving a mailed address, neither when it answers ``none``. The 213 server composes that answer and enforces it, so read it rather 214 than guessing; sending the wrong one is a 401. 215 216 ⚠️ Both are keyword-only and default blank, so a caller written 217 before the gate existed still compiles — and still works against 218 an account whose answer is ``none``, which is every account that 219 predates it. 220 221 ⚠️ **Read the answer: this does not always delete anything yet.** 222 With ``account_deletion_grace_s`` above zero — the default — the 223 account is *scheduled*: its tokens are revoked and a job runs the 224 cascade later, and signing in before then cancels the whole 225 thing. ``deleted`` says which happened and ``effective_at`` says 226 when it becomes final. A caller that discards this cannot tell 227 *gone* from *recoverable*, which are opposite facts with opposite 228 next steps. 229 """ 230 body: dict[str, str] = {"confirm_display_name": confirm_display_name} 231 if current_password: 232 body["current_password"] = current_password 233 if code: 234 body["code"] = code 235 resp = self._http.request("DELETE", "/me", json=body) 236 _raise_for_status(resp) 237 return AccountDeletionAccepted.model_validate(resp.json()) 238 239 def achievements(self, competition_id: str = "") -> MyAchievementsResponse: 240 """Caller's unlocked + locked badges (with progress hints).""" 241 params: dict[str, str] = {} 242 if competition_id: 243 params["competition_id"] = competition_id 244 resp = self._http.request("GET", "/me/achievements", params=params) 245 _raise_for_status(resp) 246 return MyAchievementsResponse.model_validate(resp.json()) 247 248 def results(self) -> MyResults: 249 """我的战绩: every started event the caller entered, newest first, each 250 at that event's own official placement (never a total). Includes the 251 sittings only the caller may see -- in progress, frozen (ranked as of 252 the freeze), anonymous, disqualified -- each with ``only_you`` set.""" 253 resp = self._http.request("GET", "/me/results") 254 _raise_for_status(resp) 255 return MyResults.model_validate(resp.json()) 256 257 def favourites(self) -> MyFavourites: 258 """Every competition and challenge the caller has starred (#2070) — 259 whole sets, and only ever the caller's own: a favourite is a private 260 preference, never shown to another person or shared with a team.""" 261 resp = self._http.request("GET", "/me/favourites") 262 _raise_for_status(resp) 263 return MyFavourites.model_validate(resp.json()) 264 265 def favourite(self, kind: str, target_id: str) -> MyFavourites: 266 """Star a ``competition`` or a ``challenge``; returns the caller's full 267 set. Idempotent. A competition the caller cannot see is a 404.""" 268 resp = self._http.request("PUT", f"/me/favourites/{kind}/{target_id}") 269 _raise_for_status(resp) 270 return MyFavourites.model_validate(resp.json()) 271 272 def unfavourite(self, kind: str, target_id: str) -> MyFavourites: 273 """Remove a star; removing one that is not there is the same answer.""" 274 resp = self._http.request("DELETE", f"/me/favourites/{kind}/{target_id}") 275 _raise_for_status(resp) 276 return MyFavourites.model_validate(resp.json()) 277 278 def solves(self, *, competition_id: str = "") -> list[MySolveSummary]: 279 """Per-challenge solve summary for the caller. 280 281 Unscoped this spans every team the caller has ever played for. 282 Pass ``competition_id`` to ask the narrower question a 283 competition-scoped surface means: have I solved this *here*. 284 """ 285 params: dict[str, str] = {} 286 if competition_id: 287 params["competition_id"] = competition_id 288 resp = self._http.request("GET", "/me/solves", params=params) 289 _raise_for_status(resp) 290 return [MySolveSummary.model_validate(s) for s in resp.json()] 291 292 def milestone_progress(self, *, competition_id: str = "") -> list[MilestoneProgress]: 293 """Partial-solve progress per challenge for the caller. 294 295 One row per challenge with at least one captured question; 296 ``solved_question_ids`` enumerates which milestones the user 297 has cleared. The row deliberately carries no question total: 298 the number of objectives is player-facing secret (#2435). 299 With ``competition_id`` set the result narrows to solves 300 stamped against the caller's team in that comp. 301 """ 302 params: dict[str, str] = {} 303 if competition_id: 304 params["competition_id"] = competition_id 305 resp = self._http.request("GET", "/me/milestone-progress", params=params) 306 _raise_for_status(resp) 307 return [MilestoneProgress.model_validate(s) for s in resp.json()] 308 309 def verify_star_gazer(self, competition_id: str = "") -> StarGazerVerifyResponse: 310 """Verify the caller starred the ctfy GitHub repo and grant the 311 ``star_gazer`` badge if so. 312 313 Friendly failures (no GitHub identity, repo not configured, not 314 yet starred, GitHub rate-limited) come back as 200 with 315 ``verified=False`` + an actionable ``reason``.""" 316 params = {"competition_id": competition_id} if competition_id else None 317 resp = self._http.request("POST", "/me/star-gazer/verify", params=params) 318 _raise_for_status(resp) 319 return StarGazerVerifyResponse.model_validate(resp.json())
Self-service surface for the authenticated caller.
32 def get(self) -> MeResponse: 33 """Get the calling user's profile + auth context. 34 35 Per-competition team memberships ride along on 36 ``competition_teams``; there is no global "current team" any 37 more — pick the row whose ``competition_id`` matches the 38 scope you care about. 39 """ 40 resp = self._http.request("GET", "/me") 41 _raise_for_status(resp) 42 return MeResponse.model_validate(resp.json())
Get the calling user's profile + auth context.
Per-competition team memberships ride along on
competition_teams; there is no global "current team" any
more — pick the row whose competition_id matches the
scope you care about.
44 def inbox(self) -> InboxResponse: 45 """Pending team invites, join requests, direct invites, announcements.""" 46 resp = self._http.request("GET", "/me/inbox") 47 _raise_for_status(resp) 48 return InboxResponse.model_validate(resp.json())
Pending team invites, join requests, direct invites, announcements.
50 def notification_preferences(self) -> NotificationPreferencesResponse: 51 """Which categories reach the caller by email. 52 53 Each cell reports ``effective`` (what happens now), ``default`` 54 (what reverting would restore) and ``explicit`` (whether the user 55 actually chose it, or is merely inheriting). Storage is sparse, so 56 an untouched cell has no row — ``explicit`` is how you tell. 57 """ 58 resp = self._http.request("GET", "/me/notification-preferences") 59 _raise_for_status(resp) 60 return NotificationPreferencesResponse.model_validate(resp.json())
Which categories reach the caller by email.
Each cell reports effective (what happens now), default
(what reverting would restore) and explicit (whether the user
actually chose it, or is merely inheriting). Storage is sparse, so
an untouched cell has no row — explicit is how you tell.
62 def set_notification_preference( 63 self, category: str, enabled: bool, *, channel: str = "email" 64 ) -> NotificationPreferencesResponse: 65 """Turn one category on or off for one channel. 66 67 ``security`` is refused with a 400 — sign-in codes and account 68 alerts exist to protect the account and are not switchable. 69 """ 70 resp = self._http.request( 71 "PATCH", 72 "/me/notification-preferences", 73 json={"category": category, "channel": channel, "enabled": enabled}, 74 ) 75 _raise_for_status(resp) 76 return NotificationPreferencesResponse.model_validate(resp.json())
Turn one category on or off for one channel.
security is refused with a 400 — sign-in codes and account
alerts exist to protect the account and are not switchable.
78 def clear_notification_preference( 79 self, category: str, *, channel: str = "email" 80 ) -> NotificationPreferencesResponse: 81 """Drop the explicit cell so the default applies again. 82 83 This is *revert*, not *disable*: for most categories the default 84 is on, so clearing an explicit ``False`` turns mail back on. 85 """ 86 resp = self._http.request("DELETE", f"/me/notification-preferences/{category}/{channel}") 87 _raise_for_status(resp) 88 return NotificationPreferencesResponse.model_validate(resp.json())
Drop the explicit cell so the default applies again.
This is revert, not disable: for most categories the default
is on, so clearing an explicit False turns mail back on.
90 def read_announcement(self, announcement_id: str) -> None: 91 """Mark one announcement as read for the calling user.""" 92 resp = self._http.request("POST", f"/me/announcements/{announcement_id}/read") 93 _raise_for_status(resp)
Mark one announcement as read for the calling user.
95 def read_all_announcements(self) -> None: 96 """Mark every relevant announcement as read.""" 97 resp = self._http.request("POST", "/me/announcements/read-all") 98 _raise_for_status(resp)
Mark every relevant announcement as read.
100 def update_profile(self, **fields: Any) -> MeResponse: 101 """PATCH the caller's profile. 102 103 Pass any subset of ``bio`` / ``country`` / ``website_url`` / 104 ``timezone`` / ``social_links``. Omit a key to leave it unchanged; 105 pass ``None`` to clear it. Returns the updated :class:`MeResponse`. 106 107 Example:: 108 109 client.me.update_profile(bio="hacking goblins", country="JP") 110 client.me.update_profile(website_url=None) # clear it 111 """ 112 resp = self._http.request("PATCH", "/me/profile", json=fields) 113 _raise_for_status(resp) 114 return MeResponse.model_validate(resp.json())
PATCH the caller's profile.
Pass any subset of bio / country / website_url /
timezone / social_links. Omit a key to leave it unchanged;
pass None to clear it. Returns the updated MeResponse.
Example::
client.me.update_profile(bio="hacking goblins", country="JP")
client.me.update_profile(website_url=None) # clear it
116 def update_visibility(self, overrides: dict[str, bool]) -> MeResponse: 117 """PATCH per-field privacy flags on the caller's profile. 118 119 Keys outside :data:`PROFILE_VISIBILITY_KEYS` are silently dropped 120 server-side. Returns the updated :class:`MeResponse`. 121 """ 122 resp = self._http.request("PATCH", "/me/profile/visibility", json=overrides) 123 _raise_for_status(resp) 124 return MeResponse.model_validate(resp.json())
PATCH per-field privacy flags on the caller's profile.
Keys outside PROFILE_VISIBILITY_KEYS are silently dropped
server-side. Returns the updated MeResponse.
126 def export_account(self) -> bytes: 127 """Download a ZIP archive of every piece of data the platform 128 holds about the caller (user, identities, tokens, memberships, 129 submissions, solves, activity). Returns raw bytes — write to a 130 file with ``Path(...).write_bytes(client.me.export_account())``.""" 131 resp = self._http.request("GET", "/me/export") 132 _raise_for_status(resp) 133 return resp.content
Download a ZIP archive of every piece of data the platform
holds about the caller (user, identities, tokens, memberships,
submissions, solves, activity). Returns raw bytes — write to a
file with Path(...).write_bytes(client.me.export_account()).
135 def reauthenticate( 136 self, 137 *, 138 current_password: str = "", 139 code: str = "", 140 ) -> ReauthenticationOpened: 141 """Prove a factor again, opening the sudo window on **this** token. 142 143 A handful of account changes — repointing the primary address, 144 setting or removing a password, unlinking an identity, minting a 145 token, widening one, adding an address, linking an identity, 146 approving a device login — refuse with ``409 147 reauthentication_required`` unless the credential making the 148 request proved a factor inside the last 149 ``reauth_window_s`` seconds. This is how it proves one. 150 151 Signing in already counts, so a freshly-minted session needs 152 nothing; this is for a bearer that has been held past the 153 window. Read ``deletion_proof`` off :meth:`get` to learn which 154 factor this account has — it is the same answer for both — and 155 pass that one. An account with neither passes with neither. 156 157 The window lands on the token in the ``Authorization`` header 158 and no other: proving on a laptop does not open it for a bearer 159 somebody else is holding. 160 """ 161 body: dict[str, str] = {} 162 if current_password: 163 body["current_password"] = current_password 164 if code: 165 body["code"] = code 166 resp = self._http.request("POST", "/me/reauthenticate", json=body) 167 _raise_for_status(resp) 168 return ReauthenticationOpened.model_validate(resp.json())
Prove a factor again, opening the sudo window on this token.
A handful of account changes — repointing the primary address,
setting or removing a password, unlinking an identity, minting a
token, widening one, adding an address, linking an identity,
approving a device login — refuse with 409
reauthentication_required unless the credential making the
request proved a factor inside the last
reauth_window_s seconds. This is how it proves one.
Signing in already counts, so a freshly-minted session needs
nothing; this is for a bearer that has been held past the
window. Read deletion_proof off get() to learn which
factor this account has — it is the same answer for both — and
pass that one. An account with neither passes with neither.
The window lands on the token in the Authorization header
and no other: proving on a laptop does not open it for a bearer
somebody else is holding.
170 def request_reauth_code(self) -> EmailVerificationSent: 171 """Mail this account's own address a code that opens the window. 172 173 Refused (409 ``reauth_proof_required``) unless a mailed code is 174 what this account re-proves with — one holding a password 175 re-enters that instead. ⚠️ A *different* code from the deletion 176 one: digits handed over under "confirm it is you" must not also 177 erase the account. 178 """ 179 resp = self._http.request("POST", "/me/reauthenticate/code") 180 _raise_for_status(resp) 181 return EmailVerificationSent.model_validate(resp.json())
Mail this account's own address a code that opens the window.
Refused (409 reauth_proof_required) unless a mailed code is
what this account re-proves with — one holding a password
re-enters that instead. ⚠️ A different code from the deletion
one: digits handed over under "confirm it is you" must not also
erase the account.
183 def request_deletion_code(self) -> EmailVerificationSent: 184 """Mail this account's own address a code that deletes it. 185 186 Refused (409 ``deletion_proof_required``) unless a mailed code is 187 what this account's proof actually is — read ``deletion_proof`` 188 off :meth:`get` first. An account with a password re-enters that 189 instead, and one with neither needs no code at all. 190 """ 191 resp = self._http.request("POST", "/me/deletion-code") 192 _raise_for_status(resp) 193 return EmailVerificationSent.model_validate(resp.json())
Mail this account's own address a code that deletes it.
Refused (409 deletion_proof_required) unless a mailed code is
what this account's proof actually is — read deletion_proof
off get() first. An account with a password re-enters that
instead, and one with neither needs no code at all.
195 def delete_account( 196 self, 197 confirm_display_name: str, 198 *, 199 current_password: str = "", 200 code: str = "", 201 ) -> AccountDeletionAccepted: 202 """Self-delete the calling user. 203 204 ``confirm_display_name`` must match the caller's display name 205 (falls back to email when empty) exactly — the typo gate against 206 fat-fingered destruction, and **not** the authorization half: it 207 is a public string. 208 209 The second factor is whichever ``deletion_proof`` on 210 :meth:`get` names — ``current_password`` for an account holding 211 a password, ``code`` from :meth:`request_deletion_code` for one 212 proving a mailed address, neither when it answers ``none``. The 213 server composes that answer and enforces it, so read it rather 214 than guessing; sending the wrong one is a 401. 215 216 ⚠️ Both are keyword-only and default blank, so a caller written 217 before the gate existed still compiles — and still works against 218 an account whose answer is ``none``, which is every account that 219 predates it. 220 221 ⚠️ **Read the answer: this does not always delete anything yet.** 222 With ``account_deletion_grace_s`` above zero — the default — the 223 account is *scheduled*: its tokens are revoked and a job runs the 224 cascade later, and signing in before then cancels the whole 225 thing. ``deleted`` says which happened and ``effective_at`` says 226 when it becomes final. A caller that discards this cannot tell 227 *gone* from *recoverable*, which are opposite facts with opposite 228 next steps. 229 """ 230 body: dict[str, str] = {"confirm_display_name": confirm_display_name} 231 if current_password: 232 body["current_password"] = current_password 233 if code: 234 body["code"] = code 235 resp = self._http.request("DELETE", "/me", json=body) 236 _raise_for_status(resp) 237 return AccountDeletionAccepted.model_validate(resp.json())
Self-delete the calling user.
confirm_display_name must match the caller's display name
(falls back to email when empty) exactly — the typo gate against
fat-fingered destruction, and not the authorization half: it
is a public string.
The second factor is whichever deletion_proof on
get() names — current_password for an account holding
a password, code from request_deletion_code() for one
proving a mailed address, neither when it answers none. The
server composes that answer and enforces it, so read it rather
than guessing; sending the wrong one is a 401.
⚠️ Both are keyword-only and default blank, so a caller written
before the gate existed still compiles — and still works against
an account whose answer is none, which is every account that
predates it.
⚠️ Read the answer: this does not always delete anything yet.
With account_deletion_grace_s above zero — the default — the
account is scheduled: its tokens are revoked and a job runs the
cascade later, and signing in before then cancels the whole
thing. deleted says which happened and effective_at says
when it becomes final. A caller that discards this cannot tell
gone from recoverable, which are opposite facts with opposite
next steps.
239 def achievements(self, competition_id: str = "") -> MyAchievementsResponse: 240 """Caller's unlocked + locked badges (with progress hints).""" 241 params: dict[str, str] = {} 242 if competition_id: 243 params["competition_id"] = competition_id 244 resp = self._http.request("GET", "/me/achievements", params=params) 245 _raise_for_status(resp) 246 return MyAchievementsResponse.model_validate(resp.json())
Caller's unlocked + locked badges (with progress hints).
248 def results(self) -> MyResults: 249 """我的战绩: every started event the caller entered, newest first, each 250 at that event's own official placement (never a total). Includes the 251 sittings only the caller may see -- in progress, frozen (ranked as of 252 the freeze), anonymous, disqualified -- each with ``only_you`` set.""" 253 resp = self._http.request("GET", "/me/results") 254 _raise_for_status(resp) 255 return MyResults.model_validate(resp.json())
我的战绩: every started event the caller entered, newest first, each
at that event's own official placement (never a total). Includes the
sittings only the caller may see -- in progress, frozen (ranked as of
the freeze), anonymous, disqualified -- each with only_you set.
257 def favourites(self) -> MyFavourites: 258 """Every competition and challenge the caller has starred (#2070) — 259 whole sets, and only ever the caller's own: a favourite is a private 260 preference, never shown to another person or shared with a team.""" 261 resp = self._http.request("GET", "/me/favourites") 262 _raise_for_status(resp) 263 return MyFavourites.model_validate(resp.json())
Every competition and challenge the caller has starred (#2070) — whole sets, and only ever the caller's own: a favourite is a private preference, never shown to another person or shared with a team.
265 def favourite(self, kind: str, target_id: str) -> MyFavourites: 266 """Star a ``competition`` or a ``challenge``; returns the caller's full 267 set. Idempotent. A competition the caller cannot see is a 404.""" 268 resp = self._http.request("PUT", f"/me/favourites/{kind}/{target_id}") 269 _raise_for_status(resp) 270 return MyFavourites.model_validate(resp.json())
Star a competition or a challenge; returns the caller's full
set. Idempotent. A competition the caller cannot see is a 404.
272 def unfavourite(self, kind: str, target_id: str) -> MyFavourites: 273 """Remove a star; removing one that is not there is the same answer.""" 274 resp = self._http.request("DELETE", f"/me/favourites/{kind}/{target_id}") 275 _raise_for_status(resp) 276 return MyFavourites.model_validate(resp.json())
Remove a star; removing one that is not there is the same answer.
278 def solves(self, *, competition_id: str = "") -> list[MySolveSummary]: 279 """Per-challenge solve summary for the caller. 280 281 Unscoped this spans every team the caller has ever played for. 282 Pass ``competition_id`` to ask the narrower question a 283 competition-scoped surface means: have I solved this *here*. 284 """ 285 params: dict[str, str] = {} 286 if competition_id: 287 params["competition_id"] = competition_id 288 resp = self._http.request("GET", "/me/solves", params=params) 289 _raise_for_status(resp) 290 return [MySolveSummary.model_validate(s) for s in resp.json()]
Per-challenge solve summary for the caller.
Unscoped this spans every team the caller has ever played for.
Pass competition_id to ask the narrower question a
competition-scoped surface means: have I solved this here.
292 def milestone_progress(self, *, competition_id: str = "") -> list[MilestoneProgress]: 293 """Partial-solve progress per challenge for the caller. 294 295 One row per challenge with at least one captured question; 296 ``solved_question_ids`` enumerates which milestones the user 297 has cleared. The row deliberately carries no question total: 298 the number of objectives is player-facing secret (#2435). 299 With ``competition_id`` set the result narrows to solves 300 stamped against the caller's team in that comp. 301 """ 302 params: dict[str, str] = {} 303 if competition_id: 304 params["competition_id"] = competition_id 305 resp = self._http.request("GET", "/me/milestone-progress", params=params) 306 _raise_for_status(resp) 307 return [MilestoneProgress.model_validate(s) for s in resp.json()]
Partial-solve progress per challenge for the caller.
One row per challenge with at least one captured question;
solved_question_ids enumerates which milestones the user
has cleared. The row deliberately carries no question total:
the number of objectives is player-facing secret (#2435).
With competition_id set the result narrows to solves
stamped against the caller's team in that comp.
309 def verify_star_gazer(self, competition_id: str = "") -> StarGazerVerifyResponse: 310 """Verify the caller starred the ctfy GitHub repo and grant the 311 ``star_gazer`` badge if so. 312 313 Friendly failures (no GitHub identity, repo not configured, not 314 yet starred, GitHub rate-limited) come back as 200 with 315 ``verified=False`` + an actionable ``reason``.""" 316 params = {"competition_id": competition_id} if competition_id else None 317 resp = self._http.request("POST", "/me/star-gazer/verify", params=params) 318 _raise_for_status(resp) 319 return StarGazerVerifyResponse.model_validate(resp.json())
Verify the caller starred the ctfy GitHub repo and grant the
star_gazer badge if so.
Friendly failures (no GitHub identity, repo not configured, not
yet starred, GitHub rate-limited) come back as 200 with
verified=False + an actionable reason.