icpc-api 0.2.0__tar.gz → 0.2.1__tar.gz
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_api-0.2.1/MANIFEST.in +4 -0
- {icpc_api-0.2.0/icpc_api.egg-info → icpc_api-0.2.1}/PKG-INFO +1 -1
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/__init__.py +1 -1
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/auth/store.py +33 -16
- {icpc_api-0.2.0 → icpc_api-0.2.1/icpc_api.egg-info}/PKG-INFO +1 -1
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc_api.egg-info/SOURCES.txt +1 -15
- {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/test_auth.py +51 -2
- icpc_api-0.2.0/MANIFEST.in +0 -2
- icpc_api-0.2.0/tests/__pycache__/__init__.cpython-312.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/__init__.cpython-312.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_auth.cpython-312-pytest-9.1.1.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_columns.cpython-312-pytest-9.1.1.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_dsl.cpython-312-pytest-9.1.1.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_join.cpython-312-pytest-9.1.1.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_models.cpython-312-pytest-9.1.1.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_paging.cpython-312-pytest-9.1.1.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_reference.cpython-312-pytest-9.1.1.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_srp.cpython-312-pytest-9.1.1.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_surveys.cpython-312-pytest-9.1.1.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_transport.cpython-312-pytest-9.1.1.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_transport_parity.cpython-312-pytest-9.1.1.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_typing.cpython-312-pytest-9.1.1.pyc +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/LICENSE +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/README.md +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/THIRD-PARTY-LICENSES.md +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/api/__init__.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/api/common.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/api/contest.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/api/person.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/api/public.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/api/staff.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/api/survey.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/api/team.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/auth/__init__.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/auth/cognito.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/auth/flows.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/auth/provider.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/auth/srp.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/auth/tokens.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/cli/__init__.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/cli/columns.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/cli/main.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/cli/render.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/config.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/errors.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/facade/__init__.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/facade/client.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/facade/domain.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/models/__init__.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/models/_generated.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/models/base.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/models/blobs.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/models/common.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/models/entities.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/models/enums.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/models/mixins.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/models/surveys.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/py.typed +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/search/__init__.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/search/_generated.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/search/dsl.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/search/endpoint.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/search/fields.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/search/surveys.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/transport/__init__.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/transport/_shared.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/transport/async_client.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/transport/operation.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/transport/sync_client.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc_api.egg-info/dependency_links.txt +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc_api.egg-info/entry_points.txt +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc_api.egg-info/requires.txt +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc_api.egg-info/top_level.txt +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/pyproject.toml +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/setup.cfg +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/__init__.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/__init__.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/test_columns.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/test_dsl.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/test_join.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/test_models.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/test_paging.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/test_reference.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/test_srp.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/test_surveys.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/test_transport.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/test_transport_parity.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/test_typing.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.2.1}/uv.lock +0 -0
|
@@ -14,7 +14,7 @@ from icpc.errors import ApiError, AuthError, IcpcError, SearchError
|
|
|
14
14
|
from icpc.facade.client import AsyncIcpc, Icpc, Include
|
|
15
15
|
from icpc.facade.domain import ContestView, Member, Team
|
|
16
16
|
|
|
17
|
-
__version__ = "0.2.
|
|
17
|
+
__version__ = "0.2.1"
|
|
18
18
|
|
|
19
19
|
__all__ = [
|
|
20
20
|
"ApiError",
|
|
@@ -1,26 +1,20 @@
|
|
|
1
1
|
"""On-disk credential store.
|
|
2
2
|
|
|
3
3
|
Credentials live in ``~/.config/icpc/credentials.json`` (or under
|
|
4
|
-
``$XDG_CONFIG_HOME``), mode 0600 in a 0700 directory
|
|
5
|
-
|
|
6
|
-
default — the one most recently logged in — so having several cached is never
|
|
7
|
-
ambiguous.
|
|
4
|
+
``$XDG_CONFIG_HOME``), mode 0600 in a 0700 directory. On Windows, where there
|
|
5
|
+
are no mode bits, the file inherits the ACL of the per-user config directory.
|
|
8
6
|
|
|
9
7
|
The file holds the Cognito tokens and, optionally, the account password.
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
|
|
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.
|
|
9
|
+
icpc.global Cognito rejects ``REFRESH_TOKEN_AUTH``, so the ID Token validity is
|
|
10
|
+
one hour. To renew it unattended, we store the password in the plaintext.
|
|
18
11
|
"""
|
|
19
12
|
|
|
20
13
|
from __future__ import annotations
|
|
21
14
|
|
|
22
15
|
import base64
|
|
23
16
|
import binascii
|
|
17
|
+
import contextlib
|
|
24
18
|
import json
|
|
25
19
|
import os
|
|
26
20
|
import tempfile
|
|
@@ -100,16 +94,17 @@ class CredentialStore:
|
|
|
100
94
|
fd, tmp_name = tempfile.mkstemp(dir=self.path.parent, prefix=".credentials-")
|
|
101
95
|
tmp = Path(tmp_name)
|
|
102
96
|
try:
|
|
103
|
-
|
|
104
|
-
if default not in accounts:
|
|
105
|
-
default = None
|
|
106
|
-
payload = {"version": VERSION, "default": default, "accounts": accounts}
|
|
97
|
+
# We need to close the file before trying to clean it up.
|
|
107
98
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
99
|
+
_restrict(handle.fileno())
|
|
100
|
+
if default not in accounts:
|
|
101
|
+
default = None
|
|
102
|
+
payload = {"version": VERSION, "default": default, "accounts": accounts}
|
|
108
103
|
json.dump(payload, handle, indent=2)
|
|
109
104
|
# Write-then-rename, so a crash cannot leave a truncated file behind.
|
|
110
105
|
tmp.replace(self.path)
|
|
111
106
|
except BaseException:
|
|
112
|
-
tmp
|
|
107
|
+
_discard(tmp)
|
|
113
108
|
raise
|
|
114
109
|
|
|
115
110
|
# ------------------------------------------------------------ accounts --
|
|
@@ -214,6 +209,28 @@ class CredentialStore:
|
|
|
214
209
|
return sorted(accounts)
|
|
215
210
|
|
|
216
211
|
|
|
212
|
+
def _restrict(fd: int) -> None:
|
|
213
|
+
"""Make the open temp file readable by its owner only.
|
|
214
|
+
|
|
215
|
+
On Windows, there is no POSIX bits, so don't do anything there.
|
|
216
|
+
"""
|
|
217
|
+
fchmod = getattr(os, "fchmod", None)
|
|
218
|
+
if fchmod is None:
|
|
219
|
+
return
|
|
220
|
+
try:
|
|
221
|
+
fchmod(fd, 0o600)
|
|
222
|
+
except (NotImplementedError, OSError):
|
|
223
|
+
# Windows implementation of `os.fchmod` has only read-only flag in 3.13.
|
|
224
|
+
if os.name == "posix":
|
|
225
|
+
raise
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _discard(tmp: Path) -> None:
|
|
229
|
+
"""Remove a half-written temp file, best effort."""
|
|
230
|
+
with contextlib.suppress(OSError):
|
|
231
|
+
tmp.unlink(missing_ok=True)
|
|
232
|
+
|
|
233
|
+
|
|
217
234
|
def _decode(value: object) -> str | None:
|
|
218
235
|
if not isinstance(value, str) or not value:
|
|
219
236
|
return None
|
|
@@ -57,7 +57,6 @@ icpc_api.egg-info/entry_points.txt
|
|
|
57
57
|
icpc_api.egg-info/requires.txt
|
|
58
58
|
icpc_api.egg-info/top_level.txt
|
|
59
59
|
tests/__init__.py
|
|
60
|
-
tests/__pycache__/__init__.cpython-312.pyc
|
|
61
60
|
tests/unit/__init__.py
|
|
62
61
|
tests/unit/test_auth.py
|
|
63
62
|
tests/unit/test_columns.py
|
|
@@ -70,17 +69,4 @@ tests/unit/test_srp.py
|
|
|
70
69
|
tests/unit/test_surveys.py
|
|
71
70
|
tests/unit/test_transport.py
|
|
72
71
|
tests/unit/test_transport_parity.py
|
|
73
|
-
tests/unit/test_typing.py
|
|
74
|
-
tests/unit/__pycache__/__init__.cpython-312.pyc
|
|
75
|
-
tests/unit/__pycache__/test_auth.cpython-312-pytest-9.1.1.pyc
|
|
76
|
-
tests/unit/__pycache__/test_columns.cpython-312-pytest-9.1.1.pyc
|
|
77
|
-
tests/unit/__pycache__/test_dsl.cpython-312-pytest-9.1.1.pyc
|
|
78
|
-
tests/unit/__pycache__/test_join.cpython-312-pytest-9.1.1.pyc
|
|
79
|
-
tests/unit/__pycache__/test_models.cpython-312-pytest-9.1.1.pyc
|
|
80
|
-
tests/unit/__pycache__/test_paging.cpython-312-pytest-9.1.1.pyc
|
|
81
|
-
tests/unit/__pycache__/test_reference.cpython-312-pytest-9.1.1.pyc
|
|
82
|
-
tests/unit/__pycache__/test_srp.cpython-312-pytest-9.1.1.pyc
|
|
83
|
-
tests/unit/__pycache__/test_surveys.cpython-312-pytest-9.1.1.pyc
|
|
84
|
-
tests/unit/__pycache__/test_transport.cpython-312-pytest-9.1.1.pyc
|
|
85
|
-
tests/unit/__pycache__/test_transport_parity.cpython-312-pytest-9.1.1.pyc
|
|
86
|
-
tests/unit/__pycache__/test_typing.cpython-312-pytest-9.1.1.pyc
|
|
72
|
+
tests/unit/test_typing.py
|
|
@@ -4,12 +4,15 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import base64
|
|
6
6
|
import json
|
|
7
|
+
import os
|
|
7
8
|
import time
|
|
9
|
+
from pathlib import Path
|
|
8
10
|
|
|
9
11
|
import httpx
|
|
10
12
|
import pytest
|
|
11
13
|
|
|
12
14
|
from icpc import errors
|
|
15
|
+
from icpc.auth import store as store_module
|
|
13
16
|
from icpc.auth.flows import CognitoAuth
|
|
14
17
|
from icpc.auth.store import PASSWORD_KEY, CredentialStore
|
|
15
18
|
from icpc.auth.tokens import TokenSet, decode_jwt_claims
|
|
@@ -24,6 +27,15 @@ def make_jwt(email: str, exp: float) -> str:
|
|
|
24
27
|
return f"{part({'alg': 'RS256'})}.{part({'email': email, 'exp': exp})}.signature"
|
|
25
28
|
|
|
26
29
|
|
|
30
|
+
def _raise(error: BaseException):
|
|
31
|
+
"""A stand-in that fails, for the failure paths of the credential store."""
|
|
32
|
+
|
|
33
|
+
def fail(*_args: object, **_kwargs: object) -> None:
|
|
34
|
+
raise error
|
|
35
|
+
|
|
36
|
+
return fail
|
|
37
|
+
|
|
38
|
+
|
|
27
39
|
def cognito(responses: list[dict], seen: list[dict] | None = None) -> CognitoAuth:
|
|
28
40
|
queue = list(responses)
|
|
29
41
|
|
|
@@ -200,8 +212,9 @@ def test_store_round_trips_tokens_and_is_private(tmp_path):
|
|
|
200
212
|
assert account.tokens == tokens
|
|
201
213
|
assert account.password is None
|
|
202
214
|
assert account.can_renew is False
|
|
203
|
-
# Credentials; the file must not be readable by anyone else.
|
|
204
|
-
|
|
215
|
+
# Credentials; the file must not be readable by anyone else on UNIX.
|
|
216
|
+
if os.name == "posix":
|
|
217
|
+
assert (tmp_path / "credentials.json").stat().st_mode & 0o077 == 0
|
|
205
218
|
|
|
206
219
|
|
|
207
220
|
def test_password_round_trips_and_enables_renewal(tmp_path):
|
|
@@ -307,6 +320,42 @@ def test_store_delete_removes_the_password_too(tmp_path):
|
|
|
307
320
|
assert store.delete("u@example.com") is False
|
|
308
321
|
|
|
309
322
|
|
|
323
|
+
def test_store_writes_on_a_platform_without_fchmod(tmp_path, monkeypatch):
|
|
324
|
+
# Windows has no POSIX permission bits, and no `os.fchmod` at all before
|
|
325
|
+
# 3.13. Saving credentials must not depend on either being there.
|
|
326
|
+
monkeypatch.delattr(os, "fchmod", raising=False)
|
|
327
|
+
|
|
328
|
+
store = CredentialStore(tmp_path / "credentials.json")
|
|
329
|
+
store.save_tokens(TokenSet(id_token="i", username="u@example.com"))
|
|
330
|
+
store.save_password("u@example.com", "password")
|
|
331
|
+
|
|
332
|
+
account = store.load("u@example.com")
|
|
333
|
+
assert account is not None
|
|
334
|
+
assert account.password == "password"
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_a_failed_write_leaves_no_temp_file_behind(tmp_path, monkeypatch):
|
|
338
|
+
store = CredentialStore(tmp_path / "credentials.json")
|
|
339
|
+
monkeypatch.setattr(store_module, "_restrict", _raise(RuntimeError("write failed")))
|
|
340
|
+
|
|
341
|
+
with pytest.raises(RuntimeError, match="write failed"):
|
|
342
|
+
store.save_tokens(TokenSet(id_token="i", username="u@example.com"))
|
|
343
|
+
assert list(tmp_path.iterdir()) == []
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def test_a_failed_write_reports_its_own_error_not_the_cleanup_failure(tmp_path, monkeypatch):
|
|
347
|
+
# On Windows we don't want to get "the process cannot access the file because it is being
|
|
348
|
+
# used by another process" instead of a real error.
|
|
349
|
+
store = CredentialStore(tmp_path / "credentials.json")
|
|
350
|
+
monkeypatch.setattr(store_module, "_restrict", _raise(RuntimeError("write failed")))
|
|
351
|
+
monkeypatch.setattr(
|
|
352
|
+
Path, "unlink", _raise(PermissionError(32, "The process cannot access the file"))
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
with pytest.raises(RuntimeError, match="write failed"):
|
|
356
|
+
store.save_tokens(TokenSet(id_token="i", username="u@example.com"))
|
|
357
|
+
|
|
358
|
+
|
|
310
359
|
def test_missing_or_corrupt_store_is_not_an_error(tmp_path):
|
|
311
360
|
assert CredentialStore(tmp_path / "absent.json").load() is None
|
|
312
361
|
corrupt = tmp_path / "corrupt.json"
|
icpc_api-0.2.0/MANIFEST.in
DELETED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|