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.
- icpc/__init__.py +32 -0
- icpc/api/__init__.py +18 -0
- icpc/api/common.py +97 -0
- icpc/api/contest.py +253 -0
- icpc/api/person.py +139 -0
- icpc/api/public.py +65 -0
- icpc/api/staff.py +83 -0
- icpc/api/team.py +335 -0
- icpc/auth/__init__.py +28 -0
- icpc/auth/cognito.py +142 -0
- icpc/auth/flows.py +314 -0
- icpc/auth/provider.py +29 -0
- icpc/auth/srp.py +185 -0
- icpc/auth/store.py +223 -0
- icpc/auth/tokens.py +86 -0
- icpc/cli/__init__.py +20 -0
- icpc/cli/columns.py +555 -0
- icpc/cli/main.py +1156 -0
- icpc/cli/render.py +160 -0
- icpc/config.py +57 -0
- icpc/errors.py +159 -0
- icpc/facade/__init__.py +6 -0
- icpc/facade/client.py +606 -0
- icpc/facade/domain.py +198 -0
- icpc/models/__init__.py +60 -0
- icpc/models/_generated.py +566 -0
- icpc/models/base.py +41 -0
- icpc/models/blobs.py +81 -0
- icpc/models/common.py +61 -0
- icpc/models/entities.py +522 -0
- icpc/models/enums.py +192 -0
- icpc/models/mixins.py +44 -0
- icpc/py.typed +0 -0
- icpc/search/__init__.py +99 -0
- icpc/search/_generated.py +1814 -0
- icpc/search/dsl.py +124 -0
- icpc/search/endpoint.py +173 -0
- icpc/search/fields.py +59 -0
- icpc/transport/__init__.py +29 -0
- icpc/transport/_shared.py +121 -0
- icpc/transport/async_client.py +120 -0
- icpc/transport/operation.py +139 -0
- icpc/transport/sync_client.py +121 -0
- icpc_api-0.1.0.dist-info/METADATA +143 -0
- icpc_api-0.1.0.dist-info/RECORD +50 -0
- icpc_api-0.1.0.dist-info/WHEEL +5 -0
- icpc_api-0.1.0.dist-info/entry_points.txt +2 -0
- icpc_api-0.1.0.dist-info/licenses/LICENSE +21 -0
- icpc_api-0.1.0.dist-info/licenses/THIRD-PARTY-LICENSES.md +220 -0
- 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()
|