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/cli/render.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Output formatting for the CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import enum
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
__all__ = ["OutputFormat", "cell", "fail", "note", "render", "warn"]
|
|
17
|
+
|
|
18
|
+
#: Data console. Markup is off: a cell holding `members[i].role` or `[dim]` is a
|
|
19
|
+
#: value, not a style, and rich would otherwise swallow the brackets.
|
|
20
|
+
_console = Console(markup=False)
|
|
21
|
+
_err = Console(stderr=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OutputFormat(enum.StrEnum):
|
|
25
|
+
"""How to print results. ``auto`` means a table on a terminal, JSON in a pipe."""
|
|
26
|
+
|
|
27
|
+
AUTO = "auto"
|
|
28
|
+
TABLE = "table"
|
|
29
|
+
JSON = "json"
|
|
30
|
+
NDJSON = "ndjson"
|
|
31
|
+
CSV = "csv"
|
|
32
|
+
TSV = "tsv"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _resolve(fmt: OutputFormat) -> OutputFormat:
|
|
36
|
+
if fmt is not OutputFormat.AUTO:
|
|
37
|
+
return fmt
|
|
38
|
+
return OutputFormat.TABLE if sys.stdout.isatty() else OutputFormat.JSON
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _plain(value: Any) -> Any:
|
|
42
|
+
if isinstance(value, BaseModel):
|
|
43
|
+
return value.model_dump(by_alias=True, exclude_none=True)
|
|
44
|
+
if isinstance(value, enum.Enum):
|
|
45
|
+
return value.value
|
|
46
|
+
if isinstance(value, list | tuple):
|
|
47
|
+
return [_plain(item) for item in value]
|
|
48
|
+
if isinstance(value, dict):
|
|
49
|
+
return {k: _plain(v) for k, v in value.items()}
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
#: Table cells wider than this are elided. Some columns carry whole JSON blobs
|
|
54
|
+
#: (staff labels, team rosters) and one of them would otherwise squeeze every
|
|
55
|
+
#: other column into a vertical stripe.
|
|
56
|
+
MAX_CELL = 60
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def cell(value: Any, *, limit: int | None = None) -> str:
|
|
60
|
+
"""One value as text: booleans as yes/no, structures as JSON, ``None`` blank."""
|
|
61
|
+
if value is None:
|
|
62
|
+
return ""
|
|
63
|
+
if isinstance(value, bool):
|
|
64
|
+
return "yes" if value else "no"
|
|
65
|
+
if isinstance(value, list | dict):
|
|
66
|
+
text = json.dumps(_plain(value), ensure_ascii=False)
|
|
67
|
+
else:
|
|
68
|
+
text = str(value)
|
|
69
|
+
if limit is not None and len(text) > limit:
|
|
70
|
+
return text[: limit - 1] + "…"
|
|
71
|
+
return text
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _tsv_cell(value: Any) -> str:
|
|
75
|
+
"""A cell without tabs or newlines, to put into a TSV file."""
|
|
76
|
+
text = cell(value)
|
|
77
|
+
for char in ("\t", "\r", "\n"):
|
|
78
|
+
text = text.replace(char, " ")
|
|
79
|
+
return text
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _rows(data: Any) -> list[dict[str, Any]]:
|
|
83
|
+
items = data if isinstance(data, list) else [data]
|
|
84
|
+
out: list[dict[str, Any]] = []
|
|
85
|
+
for item in items:
|
|
86
|
+
plain = _plain(item)
|
|
87
|
+
out.append(plain if isinstance(plain, dict) else {"value": plain})
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def render(data: Any, fmt: OutputFormat, *, columns: Sequence[str] | None = None) -> None:
|
|
92
|
+
"""Print ``data`` in the requested format.
|
|
93
|
+
|
|
94
|
+
``columns`` restricts and orders the table, CSV and TSV output; JSON always carries
|
|
95
|
+
everything, because that is what a pipeline consumer wants.
|
|
96
|
+
"""
|
|
97
|
+
resolved = _resolve(fmt)
|
|
98
|
+
|
|
99
|
+
if resolved is OutputFormat.JSON:
|
|
100
|
+
_console.print_json(json.dumps(_plain(data), ensure_ascii=False, default=str))
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
rows = _rows(data)
|
|
104
|
+
|
|
105
|
+
if resolved is OutputFormat.NDJSON:
|
|
106
|
+
for row in rows:
|
|
107
|
+
sys.stdout.write(json.dumps(row, ensure_ascii=False, default=str) + "\n")
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
if not rows:
|
|
111
|
+
_err.print("[dim]no rows[/dim]")
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
headers = list(columns) if columns else _headers(rows)
|
|
115
|
+
|
|
116
|
+
if resolved is OutputFormat.CSV:
|
|
117
|
+
writer = csv.DictWriter(sys.stdout, fieldnames=headers, extrasaction="ignore")
|
|
118
|
+
writer.writeheader()
|
|
119
|
+
for row in rows:
|
|
120
|
+
writer.writerow({key: cell(row.get(key)) for key in headers})
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
if resolved is OutputFormat.TSV:
|
|
124
|
+
sys.stdout.write("\t".join(headers) + "\n")
|
|
125
|
+
for row in rows:
|
|
126
|
+
sys.stdout.write("\t".join(_tsv_cell(row.get(key)) for key in headers) + "\n")
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
table = Table(show_lines=False, header_style="bold")
|
|
130
|
+
for header in headers:
|
|
131
|
+
table.add_column(header, overflow="fold")
|
|
132
|
+
for row in rows:
|
|
133
|
+
table.add_row(*(cell(row.get(header), limit=MAX_CELL) for header in headers))
|
|
134
|
+
_console.print(table)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _headers(rows: Sequence[dict[str, Any]]) -> list[str]:
|
|
138
|
+
"""Union of the rows' keys, in first-seen order.
|
|
139
|
+
|
|
140
|
+
Rows come back with unprojected columns dropped by ``exclude_none``, so the
|
|
141
|
+
first row alone is not a reliable header set.
|
|
142
|
+
"""
|
|
143
|
+
seen: dict[str, None] = {}
|
|
144
|
+
for row in rows:
|
|
145
|
+
for key in row:
|
|
146
|
+
seen.setdefault(key, None)
|
|
147
|
+
return list(seen)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def note(message: str) -> None:
|
|
151
|
+
"""Progress and confirmation, on stderr so stdout stays pipeable."""
|
|
152
|
+
_err.print(f"[dim]{message}[/dim]")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def warn(message: str) -> None:
|
|
156
|
+
_err.print(f"[yellow]{message}[/yellow]")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def fail(message: str) -> None:
|
|
160
|
+
_err.print(f"[red]{message}[/red]")
|
icpc/config.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Static configuration for the client.
|
|
2
|
+
|
|
3
|
+
The Cognito pool parameters belong to icpc.global's public SPA app client.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
__all__ = ["COGNITO_URL", "Settings"]
|
|
13
|
+
|
|
14
|
+
REGION = "us-east-1"
|
|
15
|
+
USER_POOL_ID = "us-east-1_WaDOo4Gqm"
|
|
16
|
+
CLIENT_ID = "6q2fe6opm0m24eoebqf9vj4emd"
|
|
17
|
+
COGNITO_URL = f"https://cognito-idp.{REGION}.amazonaws.com/"
|
|
18
|
+
|
|
19
|
+
BASE_URL = "https://icpc.global/api"
|
|
20
|
+
|
|
21
|
+
#: Page size the frontend's largest grid offers. The server accepts more, but this
|
|
22
|
+
#: is the biggest value known to be exercised in production.
|
|
23
|
+
DEFAULT_PAGE_SIZE = 1000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _default_timeout() -> httpx.Timeout:
|
|
27
|
+
return httpx.Timeout(connect=5.0, read=30.0, write=30.0, pool=5.0)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class Settings:
|
|
32
|
+
"""Tunables shared by the async and sync clients.
|
|
33
|
+
|
|
34
|
+
The concurrency and retry defaults are deliberately conservative: this is
|
|
35
|
+
someone else's production system, not ours.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
base_url: str = BASE_URL
|
|
39
|
+
cognito_url: str = COGNITO_URL
|
|
40
|
+
user_pool_id: str = USER_POOL_ID
|
|
41
|
+
client_id: str = CLIENT_ID
|
|
42
|
+
|
|
43
|
+
timeout: httpx.Timeout = field(default_factory=_default_timeout)
|
|
44
|
+
#: Read timeout used for exports and large search pages.
|
|
45
|
+
slow_read_timeout: float = 120.0
|
|
46
|
+
|
|
47
|
+
max_attempts: int = 3
|
|
48
|
+
backoff_base: float = 0.5
|
|
49
|
+
backoff_cap: float = 8.0
|
|
50
|
+
|
|
51
|
+
#: Concurrent in-flight requests allowed by the client.
|
|
52
|
+
max_concurrency: int = 4
|
|
53
|
+
|
|
54
|
+
#: Renew the id token this many seconds before it actually expires.
|
|
55
|
+
refresh_margin: float = 60.0
|
|
56
|
+
|
|
57
|
+
user_agent: str = "icpc-api"
|
icpc/errors.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Exception hierarchy.
|
|
2
|
+
|
|
3
|
+
Client-side problems (`SearchError` and friends) are raised *before* a request goes
|
|
4
|
+
out; everything under `ApiError` carries the real HTTP response.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ApiError",
|
|
13
|
+
"AuthError",
|
|
14
|
+
"BadRequest",
|
|
15
|
+
"CognitoError",
|
|
16
|
+
"ConfigError",
|
|
17
|
+
"Conflict",
|
|
18
|
+
"EmptyProjection",
|
|
19
|
+
"Forbidden",
|
|
20
|
+
"IcpcError",
|
|
21
|
+
"InvalidCredentials",
|
|
22
|
+
"InvalidFilterValue",
|
|
23
|
+
"MethodNotAllowed",
|
|
24
|
+
"MfaRequired",
|
|
25
|
+
"NotFound",
|
|
26
|
+
"RateLimited",
|
|
27
|
+
"SearchError",
|
|
28
|
+
"ServerError",
|
|
29
|
+
"TokenExpired",
|
|
30
|
+
"TransportError",
|
|
31
|
+
"Unauthorized",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
_ERROR_CODE = re.compile(r"error code \(([0-9a-f]+)\)")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class IcpcError(Exception):
|
|
38
|
+
"""Base class for every error raised by this package."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConfigError(IcpcError):
|
|
42
|
+
"""Missing or contradictory configuration (no credentials, unknown profile)."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# --------------------------------------------------------------------- auth --
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AuthError(IcpcError):
|
|
49
|
+
"""Authentication or token management failed."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class InvalidCredentials(AuthError):
|
|
53
|
+
"""Cognito rejected the username/password pair."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class MfaRequired(AuthError):
|
|
57
|
+
"""Cognito wants a software-token MFA code to finish the login."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, challenge: str, session: str, username: str) -> None:
|
|
60
|
+
super().__init__(f"MFA challenge {challenge} required for {username}")
|
|
61
|
+
self.challenge = challenge
|
|
62
|
+
self.session = session
|
|
63
|
+
self.username = username
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TokenExpired(AuthError):
|
|
67
|
+
"""The id token expired and could not be renewed."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CognitoError(AuthError):
|
|
71
|
+
"""A Cognito RPC returned an error body."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, code: str, message: str) -> None:
|
|
74
|
+
super().__init__(f"{code}: {message}")
|
|
75
|
+
self.code = code
|
|
76
|
+
self.message = message
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------- transport --
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TransportError(IcpcError):
|
|
83
|
+
"""The request never produced a response (connection, TLS or timeout)."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------- api --
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ApiError(IcpcError):
|
|
90
|
+
"""A non-2xx response from icpc.global."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, status: int, method: str, path: str, body: str) -> None:
|
|
93
|
+
super().__init__(f"HTTP {status} {method} {path}: {body[:400]}")
|
|
94
|
+
self.status = status
|
|
95
|
+
self.method = method
|
|
96
|
+
self.path = path
|
|
97
|
+
self.body = body
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class BadRequest(ApiError):
|
|
101
|
+
"""400 — usually a missing or malformed ``q``."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Unauthorized(ApiError):
|
|
105
|
+
"""401 — the id token was rejected even after a refresh."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Forbidden(ApiError):
|
|
109
|
+
"""403 — authenticated, but not entitled to this contest or team."""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class NotFound(ApiError):
|
|
113
|
+
"""404."""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class MethodNotAllowed(ApiError):
|
|
117
|
+
"""405 — the path exists but not under this verb."""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Conflict(ApiError):
|
|
121
|
+
"""409."""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class RateLimited(ApiError):
|
|
125
|
+
"""429."""
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self, status: int, method: str, path: str, body: str, retry_after: float | None
|
|
129
|
+
) -> None:
|
|
130
|
+
super().__init__(status, method, path, body)
|
|
131
|
+
self.retry_after = retry_after
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ServerError(ApiError):
|
|
135
|
+
"""5xx. ICPC's 500 bodies carry an opaque hex code worth quoting to support."""
|
|
136
|
+
|
|
137
|
+
def __init__(self, status: int, method: str, path: str, body: str) -> None:
|
|
138
|
+
super().__init__(status, method, path, body)
|
|
139
|
+
match = _ERROR_CODE.search(body)
|
|
140
|
+
self.error_code: str | None = match.group(1) if match else None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class TeamNotPromotable(ServerError):
|
|
144
|
+
"""``POST /team/{id}/promote/{siteId}`` refused: already promoted, or site conflict."""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ------------------------------------------------------------------- search --
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class SearchError(IcpcError):
|
|
151
|
+
"""The search query is invalid; raised before the request is sent."""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class InvalidFilterValue(SearchError):
|
|
155
|
+
"""A filter value contains a character the ``q`` grammar cannot escape."""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class EmptyProjection(SearchError):
|
|
159
|
+
"""A projection naming no valid field would 500 server-side."""
|
icpc/facade/__init__.py
ADDED