icpc-api 0.1.0__py3-none-any.whl

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.
Files changed (50) hide show
  1. icpc/__init__.py +32 -0
  2. icpc/api/__init__.py +18 -0
  3. icpc/api/common.py +97 -0
  4. icpc/api/contest.py +253 -0
  5. icpc/api/person.py +139 -0
  6. icpc/api/public.py +65 -0
  7. icpc/api/staff.py +83 -0
  8. icpc/api/team.py +335 -0
  9. icpc/auth/__init__.py +28 -0
  10. icpc/auth/cognito.py +142 -0
  11. icpc/auth/flows.py +314 -0
  12. icpc/auth/provider.py +29 -0
  13. icpc/auth/srp.py +185 -0
  14. icpc/auth/store.py +223 -0
  15. icpc/auth/tokens.py +86 -0
  16. icpc/cli/__init__.py +20 -0
  17. icpc/cli/columns.py +555 -0
  18. icpc/cli/main.py +1156 -0
  19. icpc/cli/render.py +160 -0
  20. icpc/config.py +57 -0
  21. icpc/errors.py +159 -0
  22. icpc/facade/__init__.py +6 -0
  23. icpc/facade/client.py +606 -0
  24. icpc/facade/domain.py +198 -0
  25. icpc/models/__init__.py +60 -0
  26. icpc/models/_generated.py +566 -0
  27. icpc/models/base.py +41 -0
  28. icpc/models/blobs.py +81 -0
  29. icpc/models/common.py +61 -0
  30. icpc/models/entities.py +522 -0
  31. icpc/models/enums.py +192 -0
  32. icpc/models/mixins.py +44 -0
  33. icpc/py.typed +0 -0
  34. icpc/search/__init__.py +99 -0
  35. icpc/search/_generated.py +1814 -0
  36. icpc/search/dsl.py +124 -0
  37. icpc/search/endpoint.py +173 -0
  38. icpc/search/fields.py +59 -0
  39. icpc/transport/__init__.py +29 -0
  40. icpc/transport/_shared.py +121 -0
  41. icpc/transport/async_client.py +120 -0
  42. icpc/transport/operation.py +139 -0
  43. icpc/transport/sync_client.py +121 -0
  44. icpc_api-0.1.0.dist-info/METADATA +143 -0
  45. icpc_api-0.1.0.dist-info/RECORD +50 -0
  46. icpc_api-0.1.0.dist-info/WHEEL +5 -0
  47. icpc_api-0.1.0.dist-info/entry_points.txt +2 -0
  48. icpc_api-0.1.0.dist-info/licenses/LICENSE +21 -0
  49. icpc_api-0.1.0.dist-info/licenses/THIRD-PARTY-LICENSES.md +220 -0
  50. icpc_api-0.1.0.dist-info/top_level.txt +1 -0
icpc/auth/store.py ADDED
@@ -0,0 +1,223 @@
1
+ """On-disk credential store.
2
+
3
+ Credentials live in ``~/.config/icpc/credentials.json`` (or under
4
+ ``$XDG_CONFIG_HOME``), mode 0600 in a 0700 directory, keyed by username so more
5
+ than one account can be cached without a profile concept. One account is the
6
+ default — the one most recently logged in — so having several cached is never
7
+ ambiguous.
8
+
9
+ The file holds the Cognito tokens and, optionally, the account password.
10
+
11
+ **The password is stored base64-encoded, which is obfuscation and not encryption.**
12
+ Anyone who can read the file can recover it in one step. It is stored at all
13
+ because this Cognito app client rejects ``REFRESH_TOKEN_AUTH``: the id token dies
14
+ after an hour and SRP needs the plaintext password to mint another, so an
15
+ unattended job has no other way to carry on. The alternative in practice is
16
+ ``ICPC_PASSWORD=…`` on a command line, which additionally leaks into shell
17
+ history and every child process.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import base64
23
+ import binascii
24
+ import json
25
+ import os
26
+ import tempfile
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from icpc.auth.tokens import TokenSet
32
+
33
+ __all__ = ["Account", "CredentialStore", "default_config_dir"]
34
+
35
+ PASSWORD_KEY = "password_base64"
36
+
37
+ #: Bumped only if the on-disk shape changes incompatibly.
38
+ VERSION = 1
39
+
40
+
41
+ def default_config_dir() -> Path:
42
+ base = os.environ.get("XDG_CONFIG_HOME")
43
+ return (Path(base) if base else Path.home() / ".config") / "icpc"
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class Account:
48
+ """One cached account."""
49
+
50
+ username: str
51
+ tokens: TokenSet | None = None
52
+ #: Plaintext, decoded on read. ``None`` when the account was saved without one.
53
+ password: str | None = None
54
+
55
+ @property
56
+ def can_renew(self) -> bool:
57
+ """Whether this account can mint a fresh token unattended.
58
+
59
+ False means the session dies when the id token expires, an hour after it
60
+ was issued.
61
+ """
62
+ return self.password is not None
63
+
64
+
65
+ class CredentialStore:
66
+ """A small JSON file of ``{username: {tokens…, password}}``."""
67
+
68
+ def __init__(self, path: Path | None = None) -> None:
69
+ self.path = path or default_config_dir() / "credentials.json"
70
+
71
+ # ------------------------------------------------------------- file io --
72
+
73
+ def _read(self) -> tuple[dict[str, dict[str, Any]], str | None]:
74
+ """Return ``(accounts, default_username)``.
75
+
76
+ Accepts the older flat ``{username: {...}}`` shape too, so an existing
77
+ file keeps working.
78
+ """
79
+ try:
80
+ raw = self.path.read_text(encoding="utf-8")
81
+ except (FileNotFoundError, NotADirectoryError):
82
+ return {}, None
83
+ try:
84
+ data = json.loads(raw)
85
+ except ValueError:
86
+ return {}, None
87
+ if not isinstance(data, dict):
88
+ return {}, None
89
+ accounts = data.get("accounts")
90
+ if isinstance(accounts, dict):
91
+ default = data.get("default")
92
+ return accounts, default if isinstance(default, str) else None
93
+ # Pre-versioned flat layout: every top-level key is an account.
94
+ return {k: v for k, v in data.items() if isinstance(v, dict)}, None
95
+
96
+ def _write(self, accounts: dict[str, dict[str, Any]], default: str | None) -> None:
97
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
98
+ # Create the temp file 0600 from the start: writing the password and
99
+ # tightening the mode afterwards would leave a readable window.
100
+ fd, tmp_name = tempfile.mkstemp(dir=self.path.parent, prefix=".credentials-")
101
+ tmp = Path(tmp_name)
102
+ try:
103
+ os.fchmod(fd, 0o600)
104
+ if default not in accounts:
105
+ default = None
106
+ payload = {"version": VERSION, "default": default, "accounts": accounts}
107
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
108
+ json.dump(payload, handle, indent=2)
109
+ # Write-then-rename, so a crash cannot leave a truncated file behind.
110
+ tmp.replace(self.path)
111
+ except BaseException:
112
+ tmp.unlink(missing_ok=True)
113
+ raise
114
+
115
+ # ------------------------------------------------------------ accounts --
116
+
117
+ def _entry(self, username: str | None) -> tuple[str, dict[str, Any]] | None:
118
+ accounts, default = self._read()
119
+ if not accounts:
120
+ return None
121
+ if username is None:
122
+ # The default is the account most recently logged in. Falling back to
123
+ # "the only one" keeps a single-account file working even if the
124
+ # default was never recorded.
125
+ key = default if default in accounts else next(iter(accounts))
126
+ if default not in accounts and len(accounts) != 1:
127
+ return None
128
+ return key, accounts[key]
129
+ entry = accounts.get(username)
130
+ return (username, entry) if entry is not None else None
131
+
132
+ def load(self, username: str | None = None) -> Account | None:
133
+ """Return the cached account for ``username``, or the default one."""
134
+ found = self._entry(username)
135
+ if found is None:
136
+ return None
137
+ key, entry = found
138
+ try:
139
+ tokens = TokenSet.from_dict(entry) if entry.get("id_token") else None
140
+ except (KeyError, TypeError, ValueError):
141
+ tokens = None
142
+ return Account(
143
+ username=entry.get("username") or key,
144
+ tokens=tokens,
145
+ password=_decode(entry.get(PASSWORD_KEY)),
146
+ )
147
+
148
+ def load_tokens(self, username: str | None = None) -> TokenSet | None:
149
+ account = self.load(username)
150
+ return account.tokens if account else None
151
+
152
+ def save_tokens(self, tokens: TokenSet, *, make_default: bool = False) -> None:
153
+ """Store or refresh an account's tokens, leaving any saved password alone."""
154
+ key = tokens.username or "default"
155
+ accounts, default = self._read()
156
+ entry = accounts.setdefault(key, {})
157
+ entry.update(tokens.to_dict())
158
+ # The first account cached becomes the default; a later one only takes
159
+ # over when it asks to, which is what a fresh `auth login` does.
160
+ self._write(accounts, key if make_default or default is None else default)
161
+
162
+ def save_password(self, username: str, password: str) -> None:
163
+ """Store the password so the session can renew itself.
164
+
165
+ See the module docstring: this is base64, not encryption.
166
+ """
167
+ accounts, default = self._read()
168
+ entry = accounts.setdefault(username, {})
169
+ entry["username"] = username
170
+ entry[PASSWORD_KEY] = base64.b64encode(password.encode()).decode()
171
+ self._write(accounts, default)
172
+
173
+ def set_default(self, username: str) -> bool:
174
+ """Make ``username`` the account used when none is named."""
175
+ accounts, _ = self._read()
176
+ if username not in accounts:
177
+ return False
178
+ self._write(accounts, username)
179
+ return True
180
+
181
+ def default_username(self) -> str | None:
182
+ accounts, default = self._read()
183
+ if default in accounts:
184
+ return default
185
+ return next(iter(accounts)) if len(accounts) == 1 else None
186
+
187
+ def forget_password(self, username: str | None = None) -> bool:
188
+ """Drop the stored password but keep the tokens. True if one was removed."""
189
+ accounts, default = self._read()
190
+ keys = list(accounts) if username is None else [username]
191
+ removed = False
192
+ for key in keys:
193
+ if accounts.get(key, {}).pop(PASSWORD_KEY, None) is not None:
194
+ removed = True
195
+ if removed:
196
+ self._write(accounts, default)
197
+ return removed
198
+
199
+ def delete(self, username: str | None = None) -> bool:
200
+ """Forget one account, or all of them. True if anything was removed."""
201
+ accounts, default = self._read()
202
+ if not accounts:
203
+ return False
204
+ if username is None:
205
+ self.path.unlink(missing_ok=True)
206
+ return True
207
+ if accounts.pop(username, None) is None:
208
+ return False
209
+ self._write(accounts, None if default == username else default)
210
+ return True
211
+
212
+ def usernames(self) -> list[str]:
213
+ accounts, _ = self._read()
214
+ return sorted(accounts)
215
+
216
+
217
+ def _decode(value: object) -> str | None:
218
+ if not isinstance(value, str) or not value:
219
+ return None
220
+ try:
221
+ return base64.b64decode(value, validate=True).decode()
222
+ except (binascii.Error, ValueError, UnicodeDecodeError):
223
+ return None
icpc/auth/tokens.py ADDED
@@ -0,0 +1,86 @@
1
+ """The token set and its expiry bookkeeping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import json
8
+ import time
9
+ from dataclasses import dataclass
10
+ from typing import Any, Self
11
+
12
+ __all__ = ["TokenSet", "decode_jwt_claims"]
13
+
14
+ #: header.payload.signature
15
+ _JWT_PARTS = 3
16
+
17
+
18
+ def decode_jwt_claims(token: str) -> dict[str, Any]:
19
+ """Read a JWT's payload without verifying it.
20
+
21
+ We never validate the signature: Cognito issued it, we only hand it back. The
22
+ claims are read for the username and the real expiry.
23
+ """
24
+ parts = token.split(".")
25
+ if len(parts) != _JWT_PARTS:
26
+ return {}
27
+ payload = parts[1]
28
+ payload += "=" * (-len(payload) % 4)
29
+ try:
30
+ return json.loads(base64.urlsafe_b64decode(payload))
31
+ except (binascii.Error, ValueError, UnicodeDecodeError):
32
+ return {}
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class TokenSet:
37
+ """Cognito's ``AuthenticationResult``.
38
+
39
+ Only :attr:`id_token` is accepted by icpc.global; the access token is not, which
40
+ is a documented and surprising property of this API.
41
+ """
42
+
43
+ id_token: str
44
+ #: Absent when the set came from a bare ``ICPC_ID_TOKEN``.
45
+ refresh_token: str | None = None
46
+ access_token: str | None = None
47
+ #: Unix time at which ``id_token`` stops being valid.
48
+ expires_at: float = 0.0
49
+ username: str | None = None
50
+
51
+ @classmethod
52
+ def from_cognito(cls, result: dict[str, Any], *, refresh_token: str | None = None) -> Self:
53
+ id_token = result["IdToken"]
54
+ claims = decode_jwt_claims(id_token)
55
+ expires_at = float(claims.get("exp") or (time.time() + result.get("ExpiresIn", 3600)))
56
+ return cls(
57
+ id_token=id_token,
58
+ # REFRESH_TOKEN_AUTH responses omit the refresh token; carry the old one.
59
+ refresh_token=result.get("RefreshToken") or refresh_token,
60
+ access_token=result.get("AccessToken"),
61
+ expires_at=expires_at,
62
+ username=claims.get("email") or claims.get("cognito:username"),
63
+ )
64
+
65
+ def expired(self, margin: float = 60.0) -> bool:
66
+ """True once the token is within ``margin`` seconds of expiring."""
67
+ return time.time() >= self.expires_at - margin
68
+
69
+ def to_dict(self) -> dict[str, Any]:
70
+ return {
71
+ "id_token": self.id_token,
72
+ "refresh_token": self.refresh_token,
73
+ "access_token": self.access_token,
74
+ "expires_at": self.expires_at,
75
+ "username": self.username,
76
+ }
77
+
78
+ @classmethod
79
+ def from_dict(cls, data: dict[str, Any]) -> Self:
80
+ return cls(
81
+ id_token=data["id_token"],
82
+ refresh_token=data.get("refresh_token"),
83
+ access_token=data.get("access_token"),
84
+ expires_at=float(data.get("expires_at", 0.0)),
85
+ username=data.get("username"),
86
+ )
icpc/cli/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """The console script with guard for missing optional extra."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ __all__ = ["run"]
8
+
9
+
10
+ def run() -> None:
11
+ try:
12
+ from icpc.cli.main import run as _run # noqa: PLC0415
13
+ except ImportError as exc:
14
+ sys.stderr.write(
15
+ f"The CLI could not run: {exc}\n"
16
+ f"Please install 'cli' extra dependencies, for example:\n"
17
+ f" uv tool install 'icpc-api[cli]'\n"
18
+ )
19
+ raise SystemExit(1) from exc
20
+ _run()