xcpcio 0.68.0__tar.gz → 0.69.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.

Potentially problematic release.


This version of xcpcio might be problematic. Click here for more details.

Files changed (56) hide show
  1. {xcpcio-0.68.0 → xcpcio-0.69.0}/PKG-INFO +1 -1
  2. {xcpcio-0.68.0 → xcpcio-0.69.0}/pyproject.toml +1 -0
  3. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/__version__.py +1 -1
  4. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/api/__init__.py +2 -0
  5. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/api/client.py +2 -2
  6. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/api/models.py +14 -3
  7. xcpcio-0.69.0/xcpcio/app/clics_uploader.py +100 -0
  8. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/clics_api_client.py +36 -0
  9. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/contest_archiver.py +1 -0
  10. xcpcio-0.69.0/xcpcio/clics/contest_uploader.py +281 -0
  11. {xcpcio-0.68.0 → xcpcio-0.69.0}/.gitignore +0 -0
  12. {xcpcio-0.68.0 → xcpcio-0.69.0}/.python-version +0 -0
  13. {xcpcio-0.68.0 → xcpcio-0.69.0}/README.md +0 -0
  14. {xcpcio-0.68.0 → xcpcio-0.69.0}/scripts/generate_ccs_models.sh +0 -0
  15. {xcpcio-0.68.0 → xcpcio-0.69.0}/tests/__init__.py +0 -0
  16. {xcpcio-0.68.0 → xcpcio-0.69.0}/tests/test_contest.py +0 -0
  17. {xcpcio-0.68.0 → xcpcio-0.69.0}/tests/test_submission.py +0 -0
  18. {xcpcio-0.68.0 → xcpcio-0.69.0}/tests/test_team.py +0 -0
  19. {xcpcio-0.68.0 → xcpcio-0.69.0}/tests/test_types.py +0 -0
  20. {xcpcio-0.68.0 → xcpcio-0.69.0}/uv.lock +0 -0
  21. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/__init__.py +0 -0
  22. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/app/clics_archiver.py +0 -0
  23. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/app/clics_server.py +0 -0
  24. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/__init__.py +0 -0
  25. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/api_server/__init__.py +0 -0
  26. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/api_server/app.py +0 -0
  27. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/api_server/dependencies.py +0 -0
  28. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/api_server/routes/__init__.py +0 -0
  29. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/api_server/routes/access.py +0 -0
  30. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/api_server/routes/accounts.py +0 -0
  31. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/api_server/routes/awards.py +0 -0
  32. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/api_server/routes/clarifications.py +0 -0
  33. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/api_server/routes/contests.py +0 -0
  34. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/api_server/routes/general.py +0 -0
  35. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/api_server/routes/groups.py +0 -0
  36. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/api_server/routes/judgement_types.py +0 -0
  37. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/api_server/routes/judgements.py +0 -0
  38. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/api_server/routes/languages.py +0 -0
  39. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/api_server/routes/organizations.py +0 -0
  40. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/api_server/routes/problems.py +0 -0
  41. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/api_server/routes/runs.py +0 -0
  42. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/api_server/routes/submissions.py +0 -0
  43. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/api_server/routes/teams.py +0 -0
  44. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/api_server/server.py +0 -0
  45. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/api_server/services/__init__.py +0 -0
  46. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/api_server/services/contest_service.py +0 -0
  47. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/base/__init__.py +0 -0
  48. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/base/types.py +0 -0
  49. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/model/__init__.py +0 -0
  50. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/model/model_2023_06/__init__.py +0 -0
  51. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/model/model_2023_06/model.py +0 -0
  52. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/reader/__init__.py +0 -0
  53. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/reader/contest_package_reader.py +0 -0
  54. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/clics/reader/interface.py +0 -0
  55. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/constants.py +0 -0
  56. {xcpcio-0.68.0 → xcpcio-0.69.0}/xcpcio/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xcpcio
3
- Version: 0.68.0
3
+ Version: 0.69.0
4
4
  Summary: xcpcio python lib
5
5
  Project-URL: homepage, https://github.com/xcpcio/xcpcio
6
6
  Project-URL: documentation, https://github.com/xcpcio/xcpcio
@@ -40,6 +40,7 @@ repository = "https://github.com/xcpcio/xcpcio"
40
40
  [project.scripts]
41
41
  clics-archiver = "xcpcio.app.clics_archiver:main"
42
42
  clics-server = "xcpcio.app.clics_server:main"
43
+ clics-uploader = "xcpcio.app.clics_uploader:main"
43
44
 
44
45
  [tool.ruff]
45
46
  line-length = 120
@@ -1,4 +1,4 @@
1
1
  # This file is auto-generated by Hatchling. As such, do not:
2
2
  # - modify
3
3
  # - track in version control e.g. be sure to add to .gitignore
4
- __version__ = VERSION = '0.68.0'
4
+ __version__ = VERSION = '0.69.0'
@@ -1,5 +1,6 @@
1
1
  from .client import ApiClient
2
2
  from .models import (
3
+ FileData,
3
4
  HTTPValidationError,
4
5
  UploadBoardDataReq,
5
6
  UploadBoardDataResp,
@@ -8,6 +9,7 @@ from .models import (
8
9
 
9
10
  __all__ = [
10
11
  ApiClient,
12
+ FileData,
11
13
  HTTPValidationError,
12
14
  UploadBoardDataReq,
13
15
  UploadBoardDataResp,
@@ -2,7 +2,7 @@ from typing import Any, Dict, Optional
2
2
 
3
3
  import aiohttp
4
4
 
5
- from .models import UploadBoardDataReq, UploadBoardDataResp
5
+ from .models import FileData, UploadBoardDataReq, UploadBoardDataResp
6
6
 
7
7
 
8
8
  class ApiClient:
@@ -51,7 +51,7 @@ class ApiClient:
51
51
  config: Optional[str] = None,
52
52
  teams: Optional[str] = None,
53
53
  submissions: Optional[str] = None,
54
- extra_files: Optional[Dict[str, str]] = None,
54
+ extra_files: Optional[Dict[str, FileData]] = None,
55
55
  ) -> UploadBoardDataResp:
56
56
  await self._ensure_session()
57
57
  await self._ensure_token()
@@ -1,4 +1,4 @@
1
- from typing import Dict, List, Optional, Union
1
+ from typing import Dict, List, Literal, Optional, Union
2
2
 
3
3
  from pydantic import BaseModel, Field
4
4
 
@@ -13,13 +13,24 @@ class HTTPValidationError(BaseModel):
13
13
  detail: Optional[List[ValidationError]] = Field(None, title="Detail")
14
14
 
15
15
 
16
+ class FileData(BaseModel):
17
+ content: str = Field(..., title="Content")
18
+ encoding: Optional[Literal["base64"]] = Field(None, title="Encoding")
19
+ checksum: Optional[str] = Field(None, title="Checksum")
20
+
21
+
16
22
  class UploadBoardDataReq(BaseModel):
17
23
  token: str = Field(..., title="Token")
18
24
  config: Optional[str] = Field(None, title="Config")
19
25
  teams: Optional[str] = Field(None, title="Teams")
20
26
  submissions: Optional[str] = Field(None, title="Submissions")
21
- extra_files: Optional[Dict[str, str]] = Field(None, title="Extra Files")
27
+ extra_files: Optional[Dict[str, FileData]] = Field(None, title="Extra Files")
28
+
29
+
30
+ class FileValidationResult(BaseModel):
31
+ checksum_valid: bool = Field(..., title="Checksum Valid")
32
+ message: Optional[str] = Field(None, title="Message")
22
33
 
23
34
 
24
35
  class UploadBoardDataResp(BaseModel):
25
- pass
36
+ file_validations: Dict[str, FileValidationResult] = Field(default_factory=dict, title="File Validations")
@@ -0,0 +1,100 @@
1
+ import asyncio
2
+ import logging
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import click
7
+
8
+ from xcpcio import __version__
9
+ from xcpcio.clics.clics_api_client import APICredentials
10
+ from xcpcio.clics.contest_uploader import ContestUploader, UploaderConfig
11
+
12
+
13
+ def setup_logging(level: str = "INFO"):
14
+ logging.basicConfig(
15
+ level=getattr(logging, level.upper()),
16
+ format="%(asctime)s [%(name)s] %(filename)s:%(lineno)d %(levelname)s: %(message)s",
17
+ )
18
+
19
+
20
+ @click.command()
21
+ @click.version_option(__version__)
22
+ @click.option("--base-url", required=True, help="Base URL of the CLICS API (e.g., https://domjudge/api)")
23
+ @click.option("--contest-id", required=True, help="Contest ID to fetch")
24
+ @click.option("--username", "-u", required=True, help="HTTP Basic Auth username for CLICS API")
25
+ @click.option("--password", "-p", required=True, help="HTTP Basic Auth password for CLICS API")
26
+ @click.option("--xcpcio-api-url", default="https://api.xcpcio.com", help="XCPCIO API URL")
27
+ @click.option("--xcpcio-api-token", required=True, help="XCPCIO API token")
28
+ @click.option(
29
+ "--cache-dir", type=click.Path(path_type=Path), help="Directory for checksum cache files (default: ~/.xcpcio/)"
30
+ )
31
+ @click.option("--timeout", default=30, type=int, help="Request timeout in seconds")
32
+ @click.option("--max-concurrent", default=10, type=int, help="Max concurrent requests for CLICS API")
33
+ @click.option(
34
+ "--log-level",
35
+ default="INFO",
36
+ type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"], case_sensitive=False),
37
+ help="Log level",
38
+ )
39
+ @click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging (same as --log-level DEBUG)")
40
+ def main(
41
+ base_url: str,
42
+ contest_id: str,
43
+ username: str,
44
+ password: str,
45
+ xcpcio_api_url: str,
46
+ xcpcio_api_token: str,
47
+ cache_dir: Optional[Path],
48
+ timeout: int,
49
+ max_concurrent: int,
50
+ log_level: str,
51
+ verbose: bool,
52
+ ):
53
+ """
54
+ Upload CLICS Contest API data to Board Admin with polling support.
55
+
56
+ This tool fetches contest data from a CLICS API and uploads raw JSON data to the xcpcio board-admin service.
57
+ It continuously polls every 5 seconds and uses SHA256 checksums to avoid uploading unchanged files.
58
+
59
+ Examples:
60
+
61
+ clics-uploader --base-url https://domjudge/api --contest-id contest123 -u admin -p secret --xcpcio-api-token YOUR_TOKEN
62
+ """
63
+
64
+ if verbose:
65
+ log_level = "DEBUG"
66
+
67
+ setup_logging(log_level)
68
+
69
+ credentials = APICredentials(username=username, password=password)
70
+ config = UploaderConfig(
71
+ clics_base_url=base_url.rstrip("/"),
72
+ contest_id=contest_id,
73
+ clics_credentials=credentials,
74
+ xcpcio_api_url=xcpcio_api_url,
75
+ xcpcio_api_token=xcpcio_api_token,
76
+ cache_dir=cache_dir,
77
+ timeout=timeout,
78
+ max_concurrent=max_concurrent,
79
+ version=__version__,
80
+ )
81
+
82
+ click.echo(f"Fetching contest '{contest_id}' from {base_url}")
83
+ click.echo(f"Uploading to XCPCIO: {xcpcio_api_url}")
84
+
85
+ async def run():
86
+ async with ContestUploader(config) as uploader:
87
+ await uploader.run_loop()
88
+
89
+ try:
90
+ asyncio.run(run())
91
+ except KeyboardInterrupt:
92
+ click.echo(click.style("\nClics Uploader stopped by user", fg="yellow"), err=True)
93
+ raise click.Abort()
94
+ except Exception as e:
95
+ click.echo(click.style(f"Error: {e}", fg="red"), err=True)
96
+ raise click.ClickException(str(e))
97
+
98
+
99
+ if __name__ == "__main__":
100
+ main()
@@ -168,6 +168,42 @@ class ClicsApiClient:
168
168
  logger.error(f"Failed to download {file_url} after retries: {e}")
169
169
  return False
170
170
 
171
+ @retry(
172
+ stop=stop_after_attempt(3),
173
+ wait=wait_exponential(multiplier=1, min=1, max=10),
174
+ retry=retry_if_exception_type((asyncio.TimeoutError, aiohttp.ClientError)),
175
+ before_sleep=before_sleep_log(logger, logging.WARNING),
176
+ reraise=True,
177
+ )
178
+ async def _fetch_file_content_internal(self, file_url: str, override_timeout: Optional[int] = None) -> bytes:
179
+ """Internal method to fetch file content as bytes with retry logic"""
180
+ logger.info(f"Fetching file content from {file_url}")
181
+ timeout = aiohttp.ClientTimeout(total=override_timeout or self._config.timeout)
182
+ async with self._session.get(file_url, timeout=timeout) as response:
183
+ if response.status != 200:
184
+ raise aiohttp.ClientResponseError(
185
+ request_info=response.request_info, history=response.history, status=response.status
186
+ )
187
+
188
+ content = await response.read()
189
+ logger.debug(f"Fetched {len(content)} bytes from {file_url}")
190
+ return content
191
+
192
+ async def fetch_file_content(self, file_url: Optional[str], override_timeout: Optional[int] = None) -> Optional[bytes]:
193
+ """Fetch file content as bytes from URL"""
194
+ if not file_url:
195
+ return None
196
+
197
+ if not file_url.startswith(("http://", "https://")):
198
+ file_url = self._build_url(file_url)
199
+
200
+ async with self._semaphore:
201
+ try:
202
+ return await self._fetch_file_content_internal(file_url, override_timeout)
203
+ except Exception as e:
204
+ logger.error(f"Failed to fetch file content from {file_url} after retries: {e}")
205
+ return None
206
+
171
207
  async def fetch_api_info(self) -> Optional[Dict[str, Any]]:
172
208
  """Fetch API root endpoint information and parse provider details"""
173
209
  data = await self.fetch_json("/")
@@ -91,6 +91,7 @@ class ContestArchiver:
91
91
  "runs",
92
92
  "clarifications",
93
93
  "awards",
94
+ "scoreboard",
94
95
  ]
95
96
 
96
97
  def __init__(self, config: ArchiveConfig):
@@ -0,0 +1,281 @@
1
+ import asyncio
2
+ import hashlib
3
+ import json
4
+ import logging
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Dict, Optional
9
+
10
+ from pydantic import BaseModel
11
+
12
+ from xcpcio.api.client import ApiClient
13
+ from xcpcio.api.models import FileData, UploadBoardDataResp
14
+ from xcpcio.clics.clics_api_client import APICredentials, ClicsApiClient, ClicsApiConfig
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ POLL_INTERVAL = 5
19
+
20
+
21
+ class FileChecksumEntry(BaseModel):
22
+ checksum: str
23
+ updated_at: str
24
+
25
+
26
+ class FileChecksumCacheData(BaseModel):
27
+ checksums: Dict[str, FileChecksumEntry]
28
+ updated_at: str
29
+
30
+
31
+ class FileChecksumCache:
32
+ def __init__(
33
+ self, cache_dir: Optional[Path] = None, contest_id: Optional[str] = None, version: Optional[str] = None
34
+ ):
35
+ if cache_dir:
36
+ cache_dir = Path(cache_dir)
37
+ else:
38
+ cache_dir = Path.home() / ".xcpcio"
39
+
40
+ cache_dir.mkdir(parents=True, exist_ok=True)
41
+
42
+ if contest_id:
43
+ self.cache_file = cache_dir / f"clics_uploader_{contest_id}.json"
44
+ else:
45
+ self.cache_file = cache_dir / "clics_uploader_cache.json"
46
+
47
+ self.version_file = cache_dir / "version"
48
+ self.version = version
49
+ self.checksums: Dict[str, FileChecksumEntry] = {}
50
+ self._load_cache()
51
+
52
+ def _load_version(self) -> Optional[str]:
53
+ if self.version_file.exists():
54
+ try:
55
+ with open(self.version_file, "r") as f:
56
+ return f.read().strip()
57
+ except Exception as e:
58
+ logger.warning(f"Failed to read version file: {e}")
59
+ return None
60
+
61
+ def _save_version(self):
62
+ if not self.version:
63
+ return
64
+ try:
65
+ with open(self.version_file, "w") as f:
66
+ f.write(self.version)
67
+ except Exception as e:
68
+ logger.warning(f"Failed to save version file: {e}")
69
+
70
+ def _load_cache(self):
71
+ cached_version = self._load_version()
72
+ if cached_version != self.version:
73
+ logger.info(f"Cache version mismatch (cached: {cached_version}, current: {self.version}), clearing cache")
74
+ self.checksums = {}
75
+ return
76
+
77
+ if self.cache_file.exists():
78
+ try:
79
+ with open(self.cache_file, "r") as f:
80
+ data = json.load(f)
81
+
82
+ cache_data = FileChecksumCacheData.model_validate(data)
83
+ self.checksums = cache_data.checksums
84
+ logger.debug(f"Loaded cache with {len(self.checksums)} entries")
85
+ except Exception as e:
86
+ logger.warning(f"Failed to load cache: {e}")
87
+ self.checksums = {}
88
+
89
+ def _save_cache(self):
90
+ try:
91
+ self.cache_file.parent.mkdir(parents=True, exist_ok=True)
92
+ cache_data = FileChecksumCacheData(
93
+ checksums=self.checksums,
94
+ updated_at=datetime.now().isoformat(),
95
+ )
96
+ with open(self.cache_file, "w") as f:
97
+ json.dump(cache_data.model_dump(), f, indent=2)
98
+ self._save_version()
99
+ logger.debug(f"Saved cache with {len(self.checksums)} entries")
100
+ except Exception as e:
101
+ logger.warning(f"Failed to save cache: {e}")
102
+
103
+ def calculate_checksum(self, content: str) -> str:
104
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()
105
+
106
+ def has_changed(self, key: str, content: str) -> bool:
107
+ checksum = self.calculate_checksum(content)
108
+ old_entry = self.checksums.get(key)
109
+ return not old_entry or old_entry.checksum != checksum
110
+
111
+ def update_checksum(self, key: str, content: str):
112
+ checksum = self.calculate_checksum(content)
113
+ self.checksums[key] = FileChecksumEntry(
114
+ checksum=checksum,
115
+ updated_at=datetime.now().isoformat(),
116
+ )
117
+
118
+ def save(self):
119
+ self._save_cache()
120
+
121
+
122
+ @dataclass
123
+ class UploaderConfig:
124
+ clics_base_url: str
125
+ contest_id: str
126
+ clics_credentials: APICredentials
127
+ xcpcio_api_url: str
128
+ xcpcio_api_token: str
129
+ cache_dir: Optional[Path] = None
130
+ timeout: int = 30
131
+ max_concurrent: int = 10
132
+ version: Optional[str] = None
133
+
134
+ def to_clics_api_config(self) -> ClicsApiConfig:
135
+ return ClicsApiConfig(
136
+ base_url=self.clics_base_url,
137
+ credentials=self.clics_credentials,
138
+ timeout=self.timeout,
139
+ max_concurrent=self.max_concurrent,
140
+ )
141
+
142
+
143
+ class ContestUploader:
144
+ ENDPOINTS_TO_FETCH = (
145
+ "access",
146
+ "accounts",
147
+ "awards",
148
+ "groups",
149
+ "judgement-types",
150
+ "judgements",
151
+ "languages",
152
+ "organizations",
153
+ "problems",
154
+ "state",
155
+ "submissions",
156
+ "teams",
157
+ )
158
+
159
+ def __init__(self, config: UploaderConfig):
160
+ self._config = config
161
+ self._clics_client = ClicsApiClient(config.to_clics_api_config())
162
+ self._api_client = ApiClient(
163
+ base_url=config.xcpcio_api_url,
164
+ token=config.xcpcio_api_token,
165
+ timeout=config.timeout,
166
+ )
167
+ self._cache = FileChecksumCache(
168
+ cache_dir=config.cache_dir,
169
+ contest_id=config.contest_id,
170
+ version=config.version,
171
+ )
172
+
173
+ async def __aenter__(self):
174
+ await self._clics_client.__aenter__()
175
+ await self._api_client.__aenter__()
176
+ return self
177
+
178
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
179
+ await self._clics_client.__aexit__(exc_type, exc_val, exc_tb)
180
+ await self._api_client.__aexit__(exc_type, exc_val, exc_tb)
181
+
182
+ def _process_file_validations(
183
+ self, file_map: Dict[str, FileData], file_keys: list[str], response: UploadBoardDataResp
184
+ ):
185
+ for file_key in file_keys:
186
+ if file_key in file_map:
187
+ validation = response.file_validations.get(file_key)
188
+ if validation:
189
+ if validation.checksum_valid:
190
+ self._cache.update_checksum(file_key, file_map[file_key].content)
191
+ else:
192
+ logger.warning(f"File checksum validation failed for {file_key}: {validation.message}")
193
+ else:
194
+ logger.warning(f"No validation result for {file_key}, will retry on next poll")
195
+
196
+ async def _fetch_contest_data(
197
+ self,
198
+ ) -> tuple[Dict[str, FileData], list[str], list[str]]:
199
+ api_files = {}
200
+ changed_api_files = []
201
+ unchanged_files = []
202
+
203
+ contest_data = await self._clics_client.fetch_json(f"contests/{self._config.contest_id}")
204
+ if contest_data:
205
+ filename = "contest.json"
206
+ content = json.dumps(contest_data, ensure_ascii=False)
207
+
208
+ if self._cache.has_changed(filename, content):
209
+ checksum = self._cache.calculate_checksum(content)
210
+ api_files[filename] = FileData(content=content, checksum=checksum)
211
+ changed_api_files.append(filename)
212
+ else:
213
+ unchanged_files.append(filename)
214
+
215
+ return api_files, changed_api_files, unchanged_files
216
+
217
+ async def _fetch_endpoint_data(
218
+ self,
219
+ api_files: Dict[str, FileData],
220
+ changed_api_files: list[str],
221
+ unchanged_files: list[str],
222
+ ):
223
+ for endpoint in self.ENDPOINTS_TO_FETCH:
224
+ data = await self._clics_client.fetch_json(f"contests/{self._config.contest_id}/{endpoint}")
225
+ if data is not None:
226
+ filename = f"{endpoint}.json"
227
+ content = json.dumps(data, ensure_ascii=False)
228
+
229
+ if self._cache.has_changed(filename, content):
230
+ checksum = self._cache.calculate_checksum(content)
231
+ api_files[filename] = FileData(content=content, checksum=checksum)
232
+ changed_api_files.append(filename)
233
+ else:
234
+ unchanged_files.append(filename)
235
+
236
+ async def _upload_api_files(
237
+ self, api_files: Dict[str, FileData], changed_api_files: list[str]
238
+ ) -> Optional[UploadBoardDataResp]:
239
+ if not api_files:
240
+ return None
241
+
242
+ logger.info(f"Uploading {len(api_files)} API file(s) to Board Admin...")
243
+ response = await self._api_client.upload_board_data(extra_files=api_files)
244
+ logger.info(f"API upload successful! ({len(api_files)} files)")
245
+
246
+ self._process_file_validations(api_files, changed_api_files, response)
247
+ self._cache.save()
248
+ return response
249
+
250
+ async def fetch_and_upload(self) -> Optional[Dict]:
251
+ await self._clics_client.fetch_api_info()
252
+
253
+ api_files, changed_api_files, unchanged_files = await self._fetch_contest_data()
254
+ await self._fetch_endpoint_data(api_files, changed_api_files, unchanged_files)
255
+
256
+ if changed_api_files:
257
+ logger.info(f"Changed API files ({len(changed_api_files)}): {', '.join(changed_api_files)}")
258
+ if unchanged_files:
259
+ logger.debug(f"Unchanged files ({len(unchanged_files)}): {', '.join(unchanged_files)}")
260
+
261
+ if not api_files:
262
+ logger.info("No changed files to upload")
263
+ return None
264
+
265
+ response = await self._upload_api_files(api_files, changed_api_files)
266
+
267
+ logger.info(f"All uploads completed! ({len(api_files)} files)")
268
+ return response.model_dump() if response else None
269
+
270
+ async def run_loop(self, poll_interval: int = POLL_INTERVAL):
271
+ iteration = 0
272
+ while True:
273
+ iteration += 1
274
+ try:
275
+ logger.info(f"[Iteration #{iteration}] Starting fetch and upload...")
276
+ await self.fetch_and_upload()
277
+ except Exception as e:
278
+ logger.exception(f"Error during fetch and upload: {e}", exc_info=True)
279
+
280
+ logger.info(f"Waiting {poll_interval} seconds until next poll...")
281
+ await asyncio.sleep(poll_interval)
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