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/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."""
@@ -0,0 +1,6 @@
1
+ """High-level clients and the joined contest model."""
2
+
3
+ from icpc.facade.client import AsyncIcpc, Icpc, Include
4
+ from icpc.facade.domain import ContestView, Member, Team, join
5
+
6
+ __all__ = ["AsyncIcpc", "ContestView", "Icpc", "Include", "Member", "Team", "join"]