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.
Files changed (89) hide show
  1. icpc_api-0.2.1/MANIFEST.in +4 -0
  2. {icpc_api-0.2.0/icpc_api.egg-info → icpc_api-0.2.1}/PKG-INFO +1 -1
  3. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/__init__.py +1 -1
  4. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/auth/store.py +33 -16
  5. {icpc_api-0.2.0 → icpc_api-0.2.1/icpc_api.egg-info}/PKG-INFO +1 -1
  6. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc_api.egg-info/SOURCES.txt +1 -15
  7. {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/test_auth.py +51 -2
  8. icpc_api-0.2.0/MANIFEST.in +0 -2
  9. icpc_api-0.2.0/tests/__pycache__/__init__.cpython-312.pyc +0 -0
  10. icpc_api-0.2.0/tests/unit/__pycache__/__init__.cpython-312.pyc +0 -0
  11. icpc_api-0.2.0/tests/unit/__pycache__/test_auth.cpython-312-pytest-9.1.1.pyc +0 -0
  12. icpc_api-0.2.0/tests/unit/__pycache__/test_columns.cpython-312-pytest-9.1.1.pyc +0 -0
  13. icpc_api-0.2.0/tests/unit/__pycache__/test_dsl.cpython-312-pytest-9.1.1.pyc +0 -0
  14. icpc_api-0.2.0/tests/unit/__pycache__/test_join.cpython-312-pytest-9.1.1.pyc +0 -0
  15. icpc_api-0.2.0/tests/unit/__pycache__/test_models.cpython-312-pytest-9.1.1.pyc +0 -0
  16. icpc_api-0.2.0/tests/unit/__pycache__/test_paging.cpython-312-pytest-9.1.1.pyc +0 -0
  17. icpc_api-0.2.0/tests/unit/__pycache__/test_reference.cpython-312-pytest-9.1.1.pyc +0 -0
  18. icpc_api-0.2.0/tests/unit/__pycache__/test_srp.cpython-312-pytest-9.1.1.pyc +0 -0
  19. icpc_api-0.2.0/tests/unit/__pycache__/test_surveys.cpython-312-pytest-9.1.1.pyc +0 -0
  20. icpc_api-0.2.0/tests/unit/__pycache__/test_transport.cpython-312-pytest-9.1.1.pyc +0 -0
  21. icpc_api-0.2.0/tests/unit/__pycache__/test_transport_parity.cpython-312-pytest-9.1.1.pyc +0 -0
  22. icpc_api-0.2.0/tests/unit/__pycache__/test_typing.cpython-312-pytest-9.1.1.pyc +0 -0
  23. {icpc_api-0.2.0 → icpc_api-0.2.1}/LICENSE +0 -0
  24. {icpc_api-0.2.0 → icpc_api-0.2.1}/README.md +0 -0
  25. {icpc_api-0.2.0 → icpc_api-0.2.1}/THIRD-PARTY-LICENSES.md +0 -0
  26. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/api/__init__.py +0 -0
  27. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/api/common.py +0 -0
  28. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/api/contest.py +0 -0
  29. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/api/person.py +0 -0
  30. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/api/public.py +0 -0
  31. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/api/staff.py +0 -0
  32. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/api/survey.py +0 -0
  33. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/api/team.py +0 -0
  34. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/auth/__init__.py +0 -0
  35. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/auth/cognito.py +0 -0
  36. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/auth/flows.py +0 -0
  37. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/auth/provider.py +0 -0
  38. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/auth/srp.py +0 -0
  39. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/auth/tokens.py +0 -0
  40. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/cli/__init__.py +0 -0
  41. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/cli/columns.py +0 -0
  42. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/cli/main.py +0 -0
  43. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/cli/render.py +0 -0
  44. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/config.py +0 -0
  45. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/errors.py +0 -0
  46. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/facade/__init__.py +0 -0
  47. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/facade/client.py +0 -0
  48. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/facade/domain.py +0 -0
  49. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/models/__init__.py +0 -0
  50. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/models/_generated.py +0 -0
  51. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/models/base.py +0 -0
  52. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/models/blobs.py +0 -0
  53. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/models/common.py +0 -0
  54. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/models/entities.py +0 -0
  55. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/models/enums.py +0 -0
  56. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/models/mixins.py +0 -0
  57. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/models/surveys.py +0 -0
  58. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/py.typed +0 -0
  59. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/search/__init__.py +0 -0
  60. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/search/_generated.py +0 -0
  61. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/search/dsl.py +0 -0
  62. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/search/endpoint.py +0 -0
  63. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/search/fields.py +0 -0
  64. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/search/surveys.py +0 -0
  65. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/transport/__init__.py +0 -0
  66. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/transport/_shared.py +0 -0
  67. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/transport/async_client.py +0 -0
  68. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/transport/operation.py +0 -0
  69. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc/transport/sync_client.py +0 -0
  70. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc_api.egg-info/dependency_links.txt +0 -0
  71. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc_api.egg-info/entry_points.txt +0 -0
  72. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc_api.egg-info/requires.txt +0 -0
  73. {icpc_api-0.2.0 → icpc_api-0.2.1}/icpc_api.egg-info/top_level.txt +0 -0
  74. {icpc_api-0.2.0 → icpc_api-0.2.1}/pyproject.toml +0 -0
  75. {icpc_api-0.2.0 → icpc_api-0.2.1}/setup.cfg +0 -0
  76. {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/__init__.py +0 -0
  77. {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/__init__.py +0 -0
  78. {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/test_columns.py +0 -0
  79. {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/test_dsl.py +0 -0
  80. {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/test_join.py +0 -0
  81. {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/test_models.py +0 -0
  82. {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/test_paging.py +0 -0
  83. {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/test_reference.py +0 -0
  84. {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/test_srp.py +0 -0
  85. {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/test_surveys.py +0 -0
  86. {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/test_transport.py +0 -0
  87. {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/test_transport_parity.py +0 -0
  88. {icpc_api-0.2.0 → icpc_api-0.2.1}/tests/unit/test_typing.py +0 -0
  89. {icpc_api-0.2.0 → icpc_api-0.2.1}/uv.lock +0 -0
@@ -0,0 +1,4 @@
1
+ graft tests
2
+ include uv.lock
3
+ global-exclude *.py[cod]
4
+ prune **/__pycache__
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: icpc-api
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Unofficial Python client and CLI for the icpc.global API
5
5
  Author-email: Nikita Sychev <root@nsychev.ru>
6
6
  License-Expression: MIT
@@ -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.0"
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, 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.
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
- **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.
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
- os.fchmod(fd, 0o600)
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.unlink(missing_ok=True)
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: icpc-api
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Unofficial Python client and CLI for the icpc.global API
5
5
  Author-email: Nikita Sychev <root@nsychev.ru>
6
6
  License-Expression: MIT
@@ -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
- assert (tmp_path / "credentials.json").stat().st_mode & 0o077 == 0
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"
@@ -1,2 +0,0 @@
1
- graft tests
2
- include uv.lock
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