icpc-api 0.3.0__tar.gz → 0.4.0__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 (77) hide show
  1. {icpc_api-0.3.0/icpc_api.egg-info → icpc_api-0.4.0}/PKG-INFO +1 -1
  2. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/__init__.py +1 -1
  3. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/cli/main.py +104 -29
  4. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/models/entities.py +19 -0
  5. {icpc_api-0.3.0 → icpc_api-0.4.0/icpc_api.egg-info}/PKG-INFO +1 -1
  6. {icpc_api-0.3.0 → icpc_api-0.4.0}/tests/unit/test_institutions.py +41 -3
  7. {icpc_api-0.3.0 → icpc_api-0.4.0}/LICENSE +0 -0
  8. {icpc_api-0.3.0 → icpc_api-0.4.0}/MANIFEST.in +0 -0
  9. {icpc_api-0.3.0 → icpc_api-0.4.0}/README.md +0 -0
  10. {icpc_api-0.3.0 → icpc_api-0.4.0}/THIRD-PARTY-LICENSES.md +0 -0
  11. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/api/__init__.py +0 -0
  12. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/api/common.py +0 -0
  13. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/api/contest.py +0 -0
  14. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/api/person.py +0 -0
  15. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/api/public.py +0 -0
  16. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/api/staff.py +0 -0
  17. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/api/survey.py +0 -0
  18. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/api/team.py +0 -0
  19. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/auth/__init__.py +0 -0
  20. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/auth/cognito.py +0 -0
  21. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/auth/flows.py +0 -0
  22. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/auth/provider.py +0 -0
  23. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/auth/srp.py +0 -0
  24. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/auth/store.py +0 -0
  25. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/auth/tokens.py +0 -0
  26. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/cli/__init__.py +0 -0
  27. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/cli/columns.py +0 -0
  28. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/cli/render.py +0 -0
  29. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/config.py +0 -0
  30. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/errors.py +0 -0
  31. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/facade/__init__.py +0 -0
  32. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/facade/client.py +0 -0
  33. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/facade/domain.py +0 -0
  34. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/models/__init__.py +0 -0
  35. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/models/_generated.py +0 -0
  36. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/models/base.py +0 -0
  37. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/models/blobs.py +0 -0
  38. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/models/common.py +0 -0
  39. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/models/countries.py +0 -0
  40. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/models/enums.py +0 -0
  41. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/models/mixins.py +0 -0
  42. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/models/surveys.py +0 -0
  43. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/py.typed +0 -0
  44. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/search/__init__.py +0 -0
  45. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/search/_generated.py +0 -0
  46. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/search/dsl.py +0 -0
  47. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/search/endpoint.py +0 -0
  48. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/search/fields.py +0 -0
  49. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/search/institutions.py +0 -0
  50. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/search/surveys.py +0 -0
  51. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/transport/__init__.py +0 -0
  52. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/transport/_shared.py +0 -0
  53. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/transport/async_client.py +0 -0
  54. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/transport/operation.py +0 -0
  55. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc/transport/sync_client.py +0 -0
  56. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc_api.egg-info/SOURCES.txt +0 -0
  57. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc_api.egg-info/dependency_links.txt +0 -0
  58. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc_api.egg-info/entry_points.txt +0 -0
  59. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc_api.egg-info/requires.txt +0 -0
  60. {icpc_api-0.3.0 → icpc_api-0.4.0}/icpc_api.egg-info/top_level.txt +0 -0
  61. {icpc_api-0.3.0 → icpc_api-0.4.0}/pyproject.toml +0 -0
  62. {icpc_api-0.3.0 → icpc_api-0.4.0}/setup.cfg +0 -0
  63. {icpc_api-0.3.0 → icpc_api-0.4.0}/tests/__init__.py +0 -0
  64. {icpc_api-0.3.0 → icpc_api-0.4.0}/tests/unit/__init__.py +0 -0
  65. {icpc_api-0.3.0 → icpc_api-0.4.0}/tests/unit/test_auth.py +0 -0
  66. {icpc_api-0.3.0 → icpc_api-0.4.0}/tests/unit/test_columns.py +0 -0
  67. {icpc_api-0.3.0 → icpc_api-0.4.0}/tests/unit/test_dsl.py +0 -0
  68. {icpc_api-0.3.0 → icpc_api-0.4.0}/tests/unit/test_join.py +0 -0
  69. {icpc_api-0.3.0 → icpc_api-0.4.0}/tests/unit/test_models.py +0 -0
  70. {icpc_api-0.3.0 → icpc_api-0.4.0}/tests/unit/test_paging.py +0 -0
  71. {icpc_api-0.3.0 → icpc_api-0.4.0}/tests/unit/test_reference.py +0 -0
  72. {icpc_api-0.3.0 → icpc_api-0.4.0}/tests/unit/test_srp.py +0 -0
  73. {icpc_api-0.3.0 → icpc_api-0.4.0}/tests/unit/test_surveys.py +0 -0
  74. {icpc_api-0.3.0 → icpc_api-0.4.0}/tests/unit/test_transport.py +0 -0
  75. {icpc_api-0.3.0 → icpc_api-0.4.0}/tests/unit/test_transport_parity.py +0 -0
  76. {icpc_api-0.3.0 → icpc_api-0.4.0}/tests/unit/test_typing.py +0 -0
  77. {icpc_api-0.3.0 → icpc_api-0.4.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: icpc-api
3
- Version: 0.3.0
3
+ Version: 0.4.0
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.3.0"
17
+ __version__ = "0.4.0"
18
18
 
19
19
  __all__ = [
20
20
  "ApiError",
@@ -8,7 +8,6 @@ from __future__ import annotations
8
8
 
9
9
  import copy
10
10
  import os
11
- import re
12
11
  import sys
13
12
  from collections.abc import Sequence
14
13
  from dataclasses import dataclass
@@ -17,6 +16,7 @@ from pathlib import Path
17
16
  from typing import Annotated, Any
18
17
 
19
18
  import typer
19
+ from pydantic import BaseModel, TypeAdapter, ValidationError
20
20
 
21
21
  from icpc import errors
22
22
  from icpc.api import common as common_api
@@ -46,6 +46,7 @@ from icpc.cli.render import OutputFormat, fail, note, render, warn
46
46
  from icpc.config import Settings
47
47
  from icpc.facade.client import Icpc, Include
48
48
  from icpc.models.countries import country
49
+ from icpc.models.entities import SiteSettings
49
50
  from icpc.models.enums import ExportType, InstitutionUnitType, MemberRole, TeamStatus
50
51
  from icpc.search import _generated as endpoints
51
52
  from icpc.search.dsl import Filter, SortKey
@@ -739,9 +740,59 @@ def contest_set_site(
739
740
  contest_api.site_settings(site_id),
740
741
  lambda o: contest_api.update_site_settings(contest_id, o),
741
742
  changes,
743
+ SiteSettings,
742
744
  )
743
745
 
744
746
 
747
+ _NEW_SITE_SETTINGS: dict[str, Any] = {
748
+ "details": "",
749
+ "allowRegistration": False,
750
+ "invitationOnly": False,
751
+ "homepage": "",
752
+ "additionalInfo": "",
753
+ "enforceCapacity": False,
754
+ "allowTeamChanges": False,
755
+ }
756
+ _NEW_SITE_REQUIRED = ("locationName", "capacity", "siteDelivery", "siteType")
757
+
758
+
759
+ @contest_app.command("add-site")
760
+ def contest_add_site(
761
+ ctx: typer.Context,
762
+ contest_id: int,
763
+ fields: Annotated[list[str], typer.Argument(help="KEY=VALUE; see below.")],
764
+ ) -> None:
765
+ """Add a site to a contest.
766
+
767
+ icpc contest add-site 1235 "name=World Finals" email=manager@icpc.global \
768
+ locationName=Dubai capacity=150 siteDelivery=ONSITE siteType=NORMAL \
769
+ allowRegistration=true enforceCapacity=true
770
+
771
+ Required: name, email, locationName, capacity, siteDelivery, siteType.
772
+ Optional: allowRegistration, invitationOnly, enforceCapacity, allowTeamChanges,
773
+ details, homepage, additionalInfo.
774
+ """
775
+ values = _assignments(fields)
776
+ body: dict[str, Any] = {"name": values.pop("name", None), "email": values.pop("email", None)}
777
+ missing = [k for k in ("name", "email") if not body[k]]
778
+ missing += [k for k in _NEW_SITE_REQUIRED if values.get(k) in (None, "")]
779
+ if missing:
780
+ raise typer.BadParameter(f"missing {', '.join(missing)}")
781
+ known = (*_NEW_SITE_REQUIRED, *_NEW_SITE_SETTINGS)
782
+ unknown = sorted(set(values) - set(known))
783
+ if unknown:
784
+ raise typer.BadParameter(
785
+ f"unknown {', '.join(unknown)}; known: name, email, {', '.join(known)}"
786
+ )
787
+ try:
788
+ settings = SiteSettings.model_validate({**_NEW_SITE_SETTINGS, **values})
789
+ except ValidationError as e:
790
+ raise typer.BadParameter(_validation_message(e)) from None
791
+ body["siteSettings"] = settings.model_dump(by_alias=True, exclude_unset=True)
792
+ with _client(ctx) as icpc:
793
+ render(icpc.send(contest_api.create_site(contest_id, body)), _ctx(ctx).output)
794
+
795
+
745
796
  @contest_app.command("summary")
746
797
  def contest_summary(ctx: typer.Context, contest_id: int) -> None:
747
798
  """Team counts by site and by status, from one joined fetch."""
@@ -869,58 +920,82 @@ def contest_load(
869
920
 
870
921
 
871
922
  def _assignments(pairs: list[str]) -> dict[str, Any]:
872
- """Parse ``key=value`` arguments, coercing the obvious literals.
923
+ """Parse ``key=value`` arguments; ``null`` becomes None, the rest stay strings.
873
924
 
874
- ``true``/``false``/``null`` and bare numbers become JSON values; everything
875
- else stays a string. Wrap a value in quotes to force a string.
925
+ The caller types them by its model, so ``zip=010000`` keeps its zero while
926
+ ``capacity=50`` turns into a number.
876
927
  """
877
928
  out: dict[str, Any] = {}
878
929
  for pair in pairs:
879
930
  key, sep, raw = pair.partition("=")
880
931
  if not sep:
881
932
  raise typer.BadParameter(f"expected KEY=VALUE, got {pair!r}")
882
- low = raw.lower()
883
- if low in ("true", "false"):
884
- out[key] = low == "true"
885
- elif low in ("null", "none"):
886
- out[key] = None
887
- elif raw.lstrip("-").isdigit():
888
- out[key] = int(raw)
889
- elif re.fullmatch(r"-?\d+\.\d+", raw):
890
- out[key] = float(raw)
891
- else:
892
- out[key] = raw.strip('"')
933
+ out[key] = None if raw.lower() in ("null", "none") else raw
893
934
  return out
894
935
 
895
936
 
896
- def _edit(ctx: typer.Context, label: str, read: Any, write: Any, pairs: list[str]) -> None:
937
+ def _validation_message(e: ValidationError) -> str:
938
+ return "; ".join(f"{'.'.join(map(str, err['loc']))}: {err['msg']}" for err in e.errors())
939
+
940
+
941
+ def _edit(
942
+ ctx: typer.Context,
943
+ label: str,
944
+ read: Any,
945
+ write: Any,
946
+ pairs: list[str],
947
+ model: type[BaseModel] | None = None,
948
+ ) -> None:
897
949
  """Read a settings object, apply ``key=value`` changes, write it back.
898
950
 
899
951
  These endpoints are full-object replaces, so the read is not optional: send
900
952
  only the changed keys and everything else is wiped. ``a.b=value`` sets a key
901
953
  of a nested object.
954
+
955
+ Values are typed by ``model``, by default the model ``read`` returns. A field
956
+ the model doesn't declare takes the type of its current value, or stays a
957
+ string if that is null.
902
958
  """
903
959
  changes = _assignments(pairs)
904
960
  with _client(ctx) as icpc:
905
961
  current = icpc.send(read)
906
- obj = current if isinstance(current, dict) else current.model_dump(by_alias=True)
907
- obj = copy.deepcopy(obj)
908
- for path, value in changes.items():
909
- *parents, key = path.split(".")
910
- target = obj
911
- for part in parents:
912
- target = target.get(part) if isinstance(target, dict) else None
913
- if not isinstance(target, dict) or key not in target:
914
- known = sorted(target) if isinstance(target, dict) else []
915
- fail(f"{label} has no field {path!r}; known here: {', '.join(known)}")
916
- raise typer.Exit(1)
917
- note(f"{path}: {target[key]!r} -> {value!r}")
918
- target[key] = value
962
+ if isinstance(current, BaseModel):
963
+ model = model or type(current)
964
+ current = current.model_dump(by_alias=True)
965
+ obj = copy.deepcopy(current)
966
+ try:
967
+ for path, value in changes.items():
968
+ *parents, key = path.split(".")
969
+ target = obj
970
+ for part in parents:
971
+ target = target.get(part) if isinstance(target, dict) else None
972
+ if not isinstance(target, dict) or key not in target:
973
+ known = sorted(target) if isinstance(target, dict) else []
974
+ fail(f"{label} has no field {path!r}; known here: {', '.join(known)}")
975
+ raise typer.Exit(1)
976
+ old = target[key]
977
+ if value is not None and isinstance(old, bool | int | float):
978
+ target[key] = TypeAdapter(type(old)).validate_python(value)
979
+ else:
980
+ target[key] = value
981
+ if model is not None:
982
+ obj = model.model_validate(obj).model_dump(by_alias=True, exclude_unset=True)
983
+ except ValidationError as e:
984
+ fail(f"bad value for {label}: {_validation_message(e)}")
985
+ raise typer.Exit(1) from None
986
+ for path in changes:
987
+ note(f"{path}: {_at(current, path)!r} -> {_at(obj, path)!r}")
919
988
  icpc.send(write(obj))
920
989
  after = icpc.send(read)
921
990
  render(after, _ctx(ctx).output)
922
991
 
923
992
 
993
+ def _at(obj: Any, path: str) -> Any:
994
+ for part in path.split("."):
995
+ obj = obj[part]
996
+ return obj
997
+
998
+
924
999
  def _grid(name: str, entity_id: int) -> SearchEndpoint[Any, Any]:
925
1000
  factory = GRIDS.get(name)
926
1001
  if factory is None:
@@ -48,6 +48,7 @@ __all__ = [
48
48
  "RegistrationInfo",
49
49
  "RegistrationStatus",
50
50
  "SiteRow",
51
+ "SiteSettings",
51
52
  "SiteTreeNode",
52
53
  "StandingRow",
53
54
  "SuggestedInstitution",
@@ -403,6 +404,24 @@ class SiteRow(Row):
403
404
  enforce_capacity: bool | None = None
404
405
 
405
406
 
407
+ class SiteSettings(Row):
408
+ """``siteSettings`` of ``GET /contest/site/{id}``, as ``contest.site_settings`` reads it."""
409
+
410
+ id: int | None = None
411
+ version: int | None = None
412
+ location_name: str | None = None
413
+ capacity: int | None = None
414
+ site_delivery: str | None = None
415
+ site_type: str | None = None
416
+ details: str | None = None
417
+ homepage: str | None = None
418
+ additional_info: str | None = None
419
+ allow_registration: bool | None = None
420
+ invitation_only: bool | None = None
421
+ enforce_capacity: bool | None = None
422
+ allow_team_changes: bool | None = None
423
+
424
+
406
425
  class SiteTreeNode(Row):
407
426
  """``GET /contest/site/tree/...`` — the contest tree, recursively."""
408
427
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: icpc-api
3
- Version: 0.3.0
3
+ Version: 0.4.0
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
@@ -1,4 +1,4 @@
1
- """Institutions: the unit's odd wire key, the grid's export path, and the CLI edit."""
1
+ """Institutions: the unit's odd wire key, the grid's export path, and the CLI edits."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -75,8 +75,9 @@ def test_the_grid_exports_from_a_sibling_path():
75
75
 
76
76
 
77
77
  class _Fake:
78
- def __init__(self) -> None:
78
+ def __init__(self, reply: Any = None) -> None:
79
79
  self.sent: list[Any] = []
80
+ self.reply = reply
80
81
 
81
82
  def __enter__(self) -> _Fake:
82
83
  return self
@@ -88,7 +89,7 @@ class _Fake:
88
89
  if op.request.method == "POST":
89
90
  self.sent.append(op.request.json)
90
91
  return None
91
- return InstitutionUnit.model_validate(UNIT)
92
+ return self.reply if self.reply is not None else InstitutionUnit.model_validate(UNIT)
92
93
 
93
94
 
94
95
  def test_set_unit_edits_nested_keys_and_sends_the_whole_object(monkeypatch):
@@ -113,6 +114,43 @@ def test_set_unit_edits_nested_keys_and_sends_the_whole_object(monkeypatch):
113
114
  assert common.update_institution_unit({}).request.path == "/common/institutionunit"
114
115
 
115
116
 
117
+ def test_set_unit_types_values_by_the_model_not_by_their_look(monkeypatch):
118
+ """`zip` is a string, so its leading zero survives; `latitude` is a float."""
119
+ fake = _Fake()
120
+ monkeypatch.setattr(main, "_client", lambda _ctx: fake)
121
+ result = CliRunner().invoke(
122
+ main.app, ["institution", "set-unit", "5695", "mailingAddress.zip=010000"]
123
+ )
124
+ assert result.exit_code == 0, result.output
125
+ assert fake.sent[0]["mailingAddress"]["zip"] == "010000"
126
+
127
+ result = CliRunner().invoke(main.app, ["institution", "set-unit", "5695", "latitude=north"])
128
+ assert result.exit_code == 1
129
+ assert len(fake.sent) == 1
130
+
131
+
132
+ def test_set_site_types_a_plain_dict_by_site_settings(monkeypatch):
133
+ """The read is a dict: typed by SiteSettings, an unmodeled key by its current value."""
134
+ current = {"id": 1, "capacity": 10, "allowRegistration": False, "futureFlag": False}
135
+ fake = _Fake(reply=current)
136
+ monkeypatch.setattr(main, "_client", lambda _ctx: fake)
137
+ result = CliRunner().invoke(
138
+ main.app,
139
+ [
140
+ "contest",
141
+ "set-site",
142
+ "1235",
143
+ "--site",
144
+ "1",
145
+ "capacity=50",
146
+ "allowRegistration=true",
147
+ "futureFlag=true",
148
+ ],
149
+ )
150
+ assert result.exit_code == 0, result.output
151
+ assert fake.sent == [{"id": 1, "capacity": 50, "allowRegistration": True, "futureFlag": True}]
152
+
153
+
116
154
  def test_set_unit_refuses_an_unknown_nested_key(monkeypatch):
117
155
  fake = _Fake()
118
156
  monkeypatch.setattr(main, "_client", lambda _ctx: fake)
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