kattis-cli 1.0.7__py3-none-any.whl → 1.1.1__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.
kattis_cli/download.py CHANGED
@@ -1,5 +1,9 @@
1
- """Download sample data and problem metadata from Kattis
2
- and save them to data folders.
1
+ """Download sample data and problem metadata from Kattis and save them.
2
+
3
+ This module provides a :class:`DownloadManager` that encapsulates
4
+ the download and metadata parsing logic. For backward compatibility the
5
+ module also exposes the original functions which delegate to a default
6
+ ``DownloadManager`` instance.
3
7
  """
4
8
 
5
9
  import shutil
@@ -12,168 +16,215 @@ from .utils import config, utility
12
16
  from . import settings
13
17
 
14
18
 
15
- def create_problem_folder(problemid: str) -> Path:
16
- """Get existing problem folder in the current path.
19
+ class DownloadManager:
20
+ """Manager responsible for fetching problem metadata and sample data."""
17
21
 
18
- Args:
19
- problemid (str): problemid
22
+ def create_problem_folder(self, problemid: str) -> Path | Any:
23
+ """Ensure and return the root folder for a problem.
20
24
 
21
- Returns:
22
- Path: problem folder
23
- """
24
- try:
25
- root_problem_folder = utility.find_problem_root_folder(
26
- Path.cwd(), f"{problemid}.yaml")
27
- except FileNotFoundError:
28
- root_problem_folder = Path.cwd().joinpath(problemid)
29
- if not root_problem_folder.exists():
30
- root_problem_folder.mkdir()
25
+ If a problem folder cannot be resolved by searching the current
26
+ working tree, create a new folder named after the problem id.
31
27
 
32
- return root_problem_folder
28
+ Args:
29
+ problemid: The Kattis problem id.
33
30
 
31
+ Returns:
32
+ Path to the root problem folder, or Any on failure.
33
+ """
34
34
 
35
- def download_html(problemid: str) -> str:
36
- """Get html content from Kattis problem page.
35
+ try:
36
+ root_problem_folder = utility.find_problem_root_folder(
37
+ Path.cwd(), f"{problemid}.yaml")
38
+ except FileNotFoundError:
39
+ root_problem_folder = Path.cwd().joinpath(problemid)
40
+ if not root_problem_folder.exists():
41
+ root_problem_folder.mkdir()
37
42
 
38
- Args:
39
- problemid (str): problemid
40
- """
41
- response = requests.get(settings.KATTIS_PROBLEM_URL + problemid, timeout=5)
42
- if response.status_code == 200:
43
- return response.text.strip()
44
- else:
45
- raise requests.exceptions.InvalidURL(f"Error: {response.status_code}")
43
+ return root_problem_folder
46
44
 
45
+ def download_html(self, problemid: str) -> str:
46
+ """Download the problem HTML page for a given problem id.
47
47
 
48
- def load_problem_metadata(problemid: str = '') -> Dict[Any, Any]:
49
- """Load problem metadata from problem folder.
48
+ Returns the HTML body as a stripped string when successful or
49
+ raises an HTTP-related exception on failure.
50
+ """
50
51
 
51
- Args:
52
- problemid (str): problemid
52
+ response = requests.get(
53
+ settings.KATTIS_PROBLEM_URL + problemid, timeout=5)
54
+ if response.status_code == 200:
55
+ return response.text.strip()
56
+ else:
57
+ raise requests.exceptions.InvalidURL(
58
+ f"Error: {response.status_code}")
53
59
 
54
- Returns:
55
- Dict: problem metadata
56
- """
57
- metadata: Dict[str, Any] = dict()
58
-
59
- filename = "*.yaml"
60
- if problemid:
61
- filename = f"{problemid}.yaml"
62
- try:
63
- # print('search ', filename, Path.cwd())
64
- root_problem_folder = utility.find_problem_root_folder(
65
- Path.cwd(), filename)
66
- if not problemid:
67
- problemid = root_problem_folder.name
68
- except FileNotFoundError:
69
- # print('file not found...')
70
- if not problemid:
71
- return metadata
60
+ def load_problem_metadata(self, problemid: str = '') -> Dict[Any, Any]:
61
+ """Load problem metadata from disk or by scraping the problem page.
62
+
63
+ If a local YAML metadata file is present it is loaded. Otherwise
64
+ the problem page is downloaded and parsed, and the metadata is
65
+ saved locally before being returned.
66
+ """
67
+
68
+ metadata: Dict[str, Any] = dict()
69
+
70
+ filename = "*.yaml"
71
+ if problemid:
72
+ filename = f"{problemid}.yaml"
73
+ try:
74
+ root_problem_folder = utility.find_problem_root_folder(
75
+ Path.cwd(), filename)
76
+ if not problemid:
77
+ problemid = root_problem_folder.name
78
+ except FileNotFoundError:
79
+ if not problemid:
80
+ return metadata
81
+ else:
82
+ root_problem_folder = Path.cwd().joinpath(problemid)
83
+ if not root_problem_folder.exists():
84
+ root_problem_folder.mkdir()
85
+
86
+ metadata_file = root_problem_folder.joinpath(f"{problemid}.yaml")
87
+ if metadata_file.exists():
88
+ with open(metadata_file, "r", encoding='utf-8') as f:
89
+ metadata = yaml.safe_load(f)
90
+ if 'submissions' not in metadata:
91
+ metadata['submissions'] = 0
92
+ metadata['accepted'] = 0
93
+ config.update_problem_metadata(problemid, metadata)
72
94
  else:
73
- root_problem_folder = Path.cwd().joinpath(problemid)
74
- if not root_problem_folder.exists():
75
- root_problem_folder.mkdir()
76
- # print('root_problem_folder', root_problem_folder)
77
- metadata_file = root_problem_folder.joinpath(f"{problemid}.yaml")
78
- # print('metadata_file', metadata_file)
79
- if metadata_file.exists():
80
- with open(metadata_file, "r", encoding='utf-8') as f:
81
- metadata = yaml.safe_load(f)
82
- if 'submissions' not in metadata:
83
- metadata['submissions'] = 0
84
- metadata['accepted'] = 0
85
- config.update_problem_metadata(problemid, metadata)
86
- else:
87
- html = download_html(problemid)
88
- metadata = _parse_metadata(problemid, html)
89
- save_metadata(problemid, metadata)
90
- return metadata
91
-
92
-
93
- def _parse_metadata(problemid: str,
94
- html: str,
95
- ) -> Dict[str, Any]:
96
- """Parse metadata from Kattis problm page.
97
- This function should only be called if
98
- the metadata file does not exist.
99
-
100
- Args:
101
- root_problem_folder (Path): problem folder
102
- problemid (str): Kattis problemid
103
-
104
- Returns:
105
- Dict: problem metadata
95
+ html = self.download_html(problemid)
96
+ metadata = self._parse_metadata(problemid, html)
97
+ self.save_metadata(problemid, metadata)
98
+ return metadata
99
+
100
+ def _parse_metadata(self, problemid: str, html: str) -> Dict[str, Any]:
101
+ """Internal parser that extracts metadata from problem HTML.
102
+
103
+ This method uses BeautifulSoup to extract title, limits,
104
+ difficulty and basic submission stats from the problem page.
105
+ """
106
+
107
+ soup = BeautifulSoup(html, "html.parser")
108
+ meta_data = {'problemid': problemid, 'title': '',
109
+ 'cpu_limit': 'None', 'mem_limit': 'None',
110
+ 'difficulty': 'None',
111
+ 'submissions': 0, 'accepted': 0}
112
+
113
+ title = soup.find("h1")
114
+ if title:
115
+ meta_data["title"] = title.text
116
+
117
+ data = soup.find("div", {"class": "metadata-grid"})
118
+ name_mapping = {'metadata-cpu-card': 'cpu_limit',
119
+ 'metadata-memmory-card': 'mem_limit',
120
+ 'metadata-difficulty-card': 'difficulty',
121
+ }
122
+
123
+ for key, key1 in name_mapping.items():
124
+ try:
125
+ value = ''
126
+ # Use .find() instead of deprecated .findChild()
127
+ div = data.find('div', {'class': key}) # type: ignore
128
+ if key == 'metadata-difficulty-card':
129
+ cls = {'class': 'difficulty_number'}
130
+ span = div.find('span', cls) # type: ignore
131
+ if span:
132
+ value += span.text.strip() + ' ' # type: ignore
133
+ cls2 = {'class': 'text-blue-200'}
134
+ span = div.find('span', cls2) # type: ignore
135
+ if span:
136
+ value += span.text.strip() # type: ignore
137
+ meta_data[key1] = value
138
+ except AttributeError:
139
+ pass
140
+ return meta_data
141
+
142
+ def parse_metadata(self, problemid: str, html: str) -> Dict[str, Any]:
143
+ """Public alias for the internal metadata parser.
144
+
145
+ Keeping a public method makes it easier for external callers and
146
+ the compatibility wrapper to access parsing without touching a
147
+ protected member.
148
+ """
149
+ return self._parse_metadata(problemid, html)
150
+
151
+ def save_metadata(self, problemid: str, metadata: Dict[str, Any]) -> None:
152
+ """Persist metadata dictionary to the problem YAML file."""
153
+
154
+ root_problem_folder = self.create_problem_folder(problemid)
155
+ metadata_file = root_problem_folder.joinpath(f"{problemid}.yaml")
156
+ with open(metadata_file, "w", encoding='utf-8') as f:
157
+ yaml.dump(metadata, f, default_flow_style=False,
158
+ allow_unicode=True)
159
+
160
+ def download_sample_data(self, problemid: str) -> None:
161
+ """Download the sample data zip for a problem and unpack it.
162
+
163
+ The samples are downloaded into a `data/` folder inside the
164
+ problem root folder and the zip is removed after extraction.
165
+ """
166
+
167
+ uri = f'{settings.KATTIS_PROBLEM_URL}{problemid}'
168
+ uri += '/file/statement/samples.zip'
169
+
170
+ root_problem_folder = self.create_problem_folder(problemid)
171
+
172
+ data_folder = root_problem_folder.joinpath('data')
173
+ data_folder.mkdir(exist_ok=True)
174
+ response = requests.get(uri, allow_redirects=True, timeout=10)
175
+ if response.status_code == 200:
176
+ zip_path = data_folder.joinpath('samples.zip')
177
+ with open(data_folder.joinpath('samples.zip'), 'wb') as f:
178
+ f.write(response.content)
179
+ shutil.unpack_archive(zip_path, data_folder)
180
+ zip_path.unlink()
181
+ else:
182
+ raise requests.exceptions.InvalidURL(
183
+ f"Error: {response.status_code}")
184
+
185
+
186
+ # Default manager instance for compatibility with existing imports
187
+ _manager = DownloadManager()
188
+
189
+
190
+ def create_problem_folder(problemid: str) -> Path:
191
+ """Module-level wrapper returning the problem root folder.
192
+
193
+ Delegates to the default :class:`DownloadManager` instance so code
194
+ using the previous functional API continues to work.
106
195
  """
107
196
 
108
- soup = BeautifulSoup(html, "html.parser")
109
- meta_data = {'problemid': problemid, 'title': '',
110
- 'cpu_limit': 'None', 'mem_limit': 'None',
111
- 'difficulty': 'None',
112
- 'submissions': 0, 'accepted': 0}
113
-
114
- # get the title of the problem
115
- title = soup.find("h1")
116
- if title:
117
- meta_data["title"] = title.text
118
-
119
- # get the cpu time limit, memory limit, difficulty, and source
120
- data = soup.find("div", {"class": "metadata-grid"})
121
- name_mapping = {'metadata-cpu-card': 'cpu_limit',
122
- 'metadata-memmory-card': 'mem_limit',
123
- 'metadata-difficulty-card': 'difficulty',
124
- }
125
-
126
- for key, key1 in name_mapping.items():
127
- try:
128
- value = ''
129
- div = data.findChild('div', {'class': key}) # type: ignore
130
- if key == 'metadata-difficulty-card':
131
- span = div.findChild( # type: ignore
132
- 'span', {
133
- 'class': 'difficulty_number'})
134
- value += span.text.strip() + ' ' # type: ignore
135
- span = div.findChild('span', # type: ignore
136
- {'class': 'text-blue-200'})
137
- value += span.text.strip() # type: ignore
138
- meta_data[key1] = value
139
- except AttributeError:
140
- pass
141
- return meta_data
197
+ return _manager.create_problem_folder(problemid)
198
+
199
+
200
+ def download_html(problemid: str) -> str:
201
+ """Download problem HTML using the module-level manager."""
202
+
203
+ return _manager.download_html(problemid)
204
+
205
+
206
+ def load_problem_metadata(problemid: str = '') -> Dict[Any, Any]:
207
+ """Load problem metadata via the default DownloadManager."""
208
+
209
+ return _manager.load_problem_metadata(problemid)
142
210
 
143
211
 
144
212
  def save_metadata(problemid: str, metadata: Dict[str, Any]) -> None:
145
- """Save metadata to problem folder.
213
+ """Persist metadata using the default DownloadManager."""
146
214
 
147
- Args:
148
- problemid (str): problemid
149
- metadata (Dict[str, Any]): metadata
150
- """
151
- root_problem_folder = create_problem_folder(problemid)
152
- metadata_file = root_problem_folder.joinpath(f"{problemid}.yaml")
153
- with open(metadata_file, "w", encoding='utf-8') as f:
154
- yaml.dump(metadata, f, default_flow_style=False, allow_unicode=True)
215
+ return _manager.save_metadata(problemid, metadata)
155
216
 
156
217
 
157
218
  def download_sample_data(problemid: str) -> None:
158
- """Download sample data from Kattis and save them to data folders.
219
+ """Download and unpack sample data using the default manager."""
220
+
221
+ return _manager.download_sample_data(problemid)
222
+
159
223
 
160
- Args:
161
- problemid (str): Kattis problem id.
224
+ def _parse_metadata(problemid: str, html: str) -> Dict[str, Any]:
225
+ """Compatibility wrapper for the legacy module-level helper used by
226
+ tests. Delegates to the DownloadManager implementation.
162
227
  """
163
- uri = f'{settings.KATTIS_PROBLEM_URL}{problemid}'
164
- uri += '/file/statement/samples.zip'
165
-
166
- root_problem_folder = create_problem_folder(problemid)
167
-
168
- data_folder = root_problem_folder.joinpath('data')
169
- data_folder.mkdir(exist_ok=True)
170
- response = requests.get(uri, allow_redirects=True, timeout=10)
171
- if response.status_code == 200:
172
- zip_path = data_folder.joinpath('samples.zip')
173
- with open(data_folder.joinpath('samples.zip'), 'wb') as f:
174
- f.write(response.content)
175
- # extract zip file
176
- shutil.unpack_archive(zip_path, data_folder)
177
- zip_path.unlink()
178
- else:
179
- raise requests.exceptions.InvalidURL(f"Error: {response.status_code}")
228
+ # Prefer the public API on the manager to avoid accessing a protected
229
+ # member from outside the class.
230
+ return _manager.parse_metadata(problemid, html)
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env python3
2
+ """Headless fireworks animation for CI/Docker.
3
+
4
+ This script intentionally avoids any GUI libraries and provides a
5
+ terminal-only fireworks animation suitable for headless environments.
6
+ Configuration can be provided via `scripts/ui_config.yml`
7
+ """
8
+
9
+ import os
10
+ import sys
11
+ import locale
12
+ import time
13
+ from random import randint, choice
14
+
15
+
16
+ def run_fireworks() -> None:
17
+ """Simple ASCII fireworks for headless environments.
18
+
19
+ Draws random bursts in the terminal using ANSI colors. Works in CI/Docker
20
+ and avoids external dependencies.
21
+ """
22
+ frames = 5
23
+ width = 60
24
+ height = 20
25
+ symbols = ['*', '•', '✶', '✸']
26
+ colors = [
27
+ '\x1b[37m', # white
28
+ '\x1b[31m', # red
29
+ '\x1b[32m', # green
30
+ '\x1b[34m', # blue
31
+ '\x1b[33m', # yellow
32
+ '\x1b[35m', # magenta
33
+ '\x1b[36m', # cyan
34
+ ]
35
+ reset = '\x1b[0m'
36
+
37
+ # Only emit ANSI color codes when stdout is a TTY and terminal
38
+ # appears to support colors. In CI or when output is captured the
39
+ # escape characters may be shown literally (e.g. as ``\u001b[...]``)
40
+ # so we disable color codes in that case to avoid escaped sequences
41
+ # appearing in logs.
42
+ use_color = sys.stdout.isatty() and os.environ.get('NO_COLOR') is None
43
+ if os.environ.get('TERM') == 'dumb':
44
+ use_color = False
45
+ if not use_color:
46
+ colors = ['']
47
+ reset = ''
48
+
49
+ # Detect Unicode support in the execution environment. Some minimal
50
+ # container images or redirected outputs use an ASCII locale so fancy
51
+ # symbols (✶, ✸, •, emojis) render poorly or get replaced. We fall
52
+ # back to ASCII symbols when Unicode is not supported.
53
+ out_encoding = sys.stdout.encoding or \
54
+ locale.getpreferredencoding(False) or ''
55
+ out_encoding = (out_encoding or '').lower()
56
+ supports_unicode = ('utf' in out_encoding)
57
+ if not supports_unicode:
58
+ # Simple ASCII fallback symbols
59
+ symbols = ['*', '+', 'o', '.']
60
+ # Replace message with ASCII-only fallback if necessary
61
+ # Inform interactive users why we fell back (but avoid noisy logs
62
+ # in captured/non-interactive runs).
63
+ if sys.stdout.isatty():
64
+ print(
65
+ 'Note: terminal does not appear to support UTF-8 — '
66
+ 'using ASCII fallback for fireworks.',
67
+ file=sys.stderr,
68
+ )
69
+
70
+ try:
71
+ # Hide cursor (only when terminal supports it)
72
+ if use_color:
73
+ sys.stdout.write('\x1b[?25l')
74
+ for _ in range(frames):
75
+ # clear screen when supported, otherwise don't emit escapes
76
+ if use_color:
77
+ sys.stdout.write('\x1b[2J\x1b[H')
78
+ cx = randint(width // 4, 3 * width // 4)
79
+ cy = randint(height // 4, 3 * height // 4)
80
+ grid = [[' ' for _ in range(width)] for _ in range(height)]
81
+ # place particles
82
+ for _ in range(randint(12, 40)):
83
+ dx = int(randint(-8, 8) * (randint(0, 100) / 100.0))
84
+ dy = int(randint(-4, 4) * (randint(0, 100) / 100.0))
85
+ x = max(0, min(width - 1, cx + dx))
86
+ y = max(0, min(height - 1, cy + dy))
87
+ grid[y][x] = choice(symbols)
88
+
89
+ # render
90
+ for row in grid:
91
+ line = ''
92
+ for ch in row:
93
+ if ch == ' ':
94
+ line += ' '
95
+ else:
96
+ line += choice(colors) + ch + reset
97
+ sys.stdout.write(line + '\n')
98
+ sys.stdout.flush()
99
+ time.sleep(0.12)
100
+
101
+ # final message
102
+ msg = '🎉 Congratulations — Accepted! 🎉'
103
+ sys.stdout.write('\n')
104
+ if use_color:
105
+ sys.stdout.write('\x1b[1;33m')
106
+ sys.stdout.write(msg)
107
+ sys.stdout.write('\x1b[0m\n')
108
+ else:
109
+ sys.stdout.write(msg + '\n')
110
+ sys.stdout.flush()
111
+ finally:
112
+ # show cursor again
113
+ if use_color:
114
+ sys.stdout.write('\x1b[?25h')
115
+ sys.stdout.flush()
116
+
117
+
118
+ if __name__ == '__main__':
119
+ run_fireworks()