AtCoderStudyBooster 0.2__py3-none-any.whl → 0.3.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.
- atcdr/download.py +251 -240
- atcdr/generate.py +184 -193
- atcdr/login.py +133 -0
- atcdr/logout.py +24 -0
- atcdr/main.py +24 -17
- atcdr/markdown.py +22 -22
- atcdr/open.py +30 -30
- atcdr/submit.py +297 -0
- atcdr/test.py +382 -244
- atcdr/util/execute.py +52 -52
- atcdr/util/filetype.py +81 -67
- atcdr/util/gpt.py +102 -96
- atcdr/util/parse.py +206 -0
- atcdr/util/problem.py +94 -91
- atcdr/util/session.py +140 -0
- atcoderstudybooster-0.3.1.dist-info/METADATA +205 -0
- atcoderstudybooster-0.3.1.dist-info/RECORD +21 -0
- {atcoderstudybooster-0.2.dist-info → atcoderstudybooster-0.3.1.dist-info}/WHEEL +1 -1
- atcdr/util/cost.py +0 -120
- atcoderstudybooster-0.2.dist-info/METADATA +0 -96
- atcoderstudybooster-0.2.dist-info/RECORD +0 -17
- {atcoderstudybooster-0.2.dist-info → atcoderstudybooster-0.3.1.dist-info}/entry_points.txt +0 -0
atcdr/download.py
CHANGED
@@ -1,289 +1,300 @@
|
|
1
1
|
import os
|
2
2
|
import re
|
3
3
|
import time
|
4
|
-
from
|
5
|
-
from enum import Enum
|
6
|
-
from typing import Callable, List, Optional, Union, cast
|
4
|
+
from typing import Callable, List, Union, cast
|
7
5
|
|
8
6
|
import questionary as q
|
9
|
-
import
|
10
|
-
from rich.
|
11
|
-
from rich.prompt import IntPrompt, Prompt
|
7
|
+
from rich import print
|
8
|
+
from rich.prompt import Prompt
|
12
9
|
|
13
10
|
from atcdr.util.filetype import FILE_EXTENSIONS, Lang
|
14
|
-
from atcdr.util.
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
f'[bold yellow][Error {response.status_code}][/bold yellow] リダイレクトが発生しました。abc{problem.number} {problem.difficulty.value}'
|
58
|
-
)
|
59
|
-
elif 400 <= response.status_code < 500:
|
60
|
-
console.print(
|
61
|
-
f'[bold red][Error {response.status_code}][/bold red] 問題が見つかりません。abc{problem.number} {problem.difficulty.value}'
|
62
|
-
)
|
63
|
-
break
|
64
|
-
elif 500 <= response.status_code < 600:
|
65
|
-
console.print(
|
66
|
-
f'[bold red][Error {response.status_code}][/bold red] サーバーエラーが発生しました。abc{problem.number} {problem.difficulty.value}'
|
67
|
-
)
|
68
|
-
break
|
69
|
-
else:
|
70
|
-
console.print(
|
71
|
-
f'[bold red][Error {response.status_code}][/bold red] abc{problem.number} {problem.difficulty.value}に対応するHTMLファイルを取得できませんでした。'
|
72
|
-
)
|
73
|
-
break
|
74
|
-
return None
|
75
|
-
|
76
|
-
|
77
|
-
def save_file(file_path: str, html: str) -> None:
|
78
|
-
with open(file_path, 'w', encoding='utf-8') as file:
|
79
|
-
file.write(html)
|
80
|
-
console.print(f'[bold green][+][/bold green] ファイルを保存しました :{file_path}')
|
11
|
+
from atcdr.util.parse import ProblemHTML
|
12
|
+
from atcdr.util.problem import Contest, Diff, Problem
|
13
|
+
from atcdr.util.session import load_session
|
14
|
+
|
15
|
+
|
16
|
+
class Downloader:
|
17
|
+
def __init__(self) -> None:
|
18
|
+
self.session = load_session()
|
19
|
+
|
20
|
+
def get(self, problem: Problem) -> ProblemHTML:
|
21
|
+
session = self.session
|
22
|
+
retry_attempts = 3
|
23
|
+
retry_wait = 1 # 1 second
|
24
|
+
|
25
|
+
for _ in range(retry_attempts):
|
26
|
+
response = session.get(problem.url)
|
27
|
+
if response.status_code == 200:
|
28
|
+
return ProblemHTML(response.text)
|
29
|
+
elif response.status_code == 429:
|
30
|
+
print(
|
31
|
+
f'[bold yellow][Error {response.status_code}][/bold yellow] 再試行します。{problem}'
|
32
|
+
)
|
33
|
+
time.sleep(retry_wait)
|
34
|
+
elif 300 <= response.status_code < 400:
|
35
|
+
print(
|
36
|
+
f'[bold yellow][Error {response.status_code}][/bold yellow] リダイレクトが発生しました。{problem}'
|
37
|
+
)
|
38
|
+
elif 400 <= response.status_code < 500:
|
39
|
+
print(
|
40
|
+
f'[bold red][Error {response.status_code}][/bold red] 問題が見つかりません。{problem}'
|
41
|
+
)
|
42
|
+
break
|
43
|
+
elif 500 <= response.status_code < 600:
|
44
|
+
print(
|
45
|
+
f'[bold red][Error {response.status_code}][/bold red] サーバーエラーが発生しました。{problem}'
|
46
|
+
)
|
47
|
+
break
|
48
|
+
else:
|
49
|
+
print(
|
50
|
+
f'[bold red][Error {response.status_code}][/bold red] {problem}に対応するHTMLファイルを取得できませんでした。'
|
51
|
+
)
|
52
|
+
break
|
53
|
+
return ProblemHTML('')
|
81
54
|
|
82
55
|
|
83
56
|
def mkdir(path: str) -> None:
|
84
|
-
|
85
|
-
|
86
|
-
|
57
|
+
if not os.path.exists(path):
|
58
|
+
os.makedirs(path)
|
59
|
+
print(f'[bold green][+][/bold green] フォルダー: {path} を作成しました')
|
87
60
|
|
88
61
|
|
89
62
|
class GenerateMode:
|
90
|
-
|
91
|
-
|
92
|
-
|
63
|
+
@staticmethod
|
64
|
+
def gene_path_on_diff(base: str, problem: Problem) -> str:
|
65
|
+
return (
|
66
|
+
os.path.join(base, problem.label, f'{problem.contest.number:03}')
|
67
|
+
if problem.contest.number
|
68
|
+
else os.path.join(base, problem.label, problem.contest.contest)
|
69
|
+
)
|
93
70
|
|
94
|
-
|
95
|
-
|
96
|
-
|
71
|
+
@staticmethod
|
72
|
+
def gene_path_on_num(base: str, problem: Problem) -> str:
|
73
|
+
return (
|
74
|
+
os.path.join(base, f'{problem.contest.number:03}', problem.label)
|
75
|
+
if problem.contest.number
|
76
|
+
else os.path.join(base, problem.contest.contest, problem.label)
|
77
|
+
)
|
78
|
+
|
79
|
+
|
80
|
+
def title_to_filename(title: str) -> str:
|
81
|
+
title = re.sub(r'[\\/*?:"<>| !@#$%^&()+=\[\]{};,\']', '', title)
|
82
|
+
title = re.sub(r'.*?-', '', title)
|
83
|
+
return title
|
97
84
|
|
98
85
|
|
99
86
|
def generate_problem_directory(
|
100
|
-
|
87
|
+
base_path: str, problems: List[Problem], gene_path: Callable[[str, Problem], str]
|
101
88
|
) -> None:
|
102
|
-
|
103
|
-
|
89
|
+
downloader = Downloader()
|
90
|
+
for problem in problems:
|
91
|
+
problem_content = downloader.get(problem)
|
92
|
+
if not problem_content:
|
93
|
+
print(f'[bold red][Error][/] {problem}の保存に失敗しました')
|
94
|
+
continue
|
104
95
|
|
105
|
-
|
106
|
-
|
107
|
-
continue
|
96
|
+
dir_path = gene_path(base_path, problem)
|
97
|
+
mkdir(dir_path)
|
108
98
|
|
109
|
-
|
110
|
-
if not title:
|
111
|
-
console.print('[bold red][Error][/bold red] タイトルが取得できませんでした')
|
112
|
-
title = f'problem{problem.number}{problem.difficulty.value}'
|
99
|
+
problem_content.repair_me()
|
113
100
|
|
114
|
-
|
101
|
+
title = problem_content.title or problem.label
|
102
|
+
title = title_to_filename(title)
|
115
103
|
|
116
|
-
|
117
|
-
|
104
|
+
html_path = os.path.join(dir_path, title + FILE_EXTENSIONS[Lang.HTML])
|
105
|
+
with open(html_path, 'w', encoding='utf-8') as file:
|
106
|
+
file.write(problem_content.html)
|
107
|
+
print(f'[bold green][+][/bold green] ファイルを保存しました :{html_path}')
|
118
108
|
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
109
|
+
md = problem_content.make_problem_markdown('ja')
|
110
|
+
md_path = os.path.join(dir_path, title + FILE_EXTENSIONS[Lang.MARKDOWN])
|
111
|
+
with open(md_path, 'w', encoding='utf-8') as file:
|
112
|
+
file.write(md)
|
113
|
+
print(f'[bold green][+][/bold green] ファイルを保存しました :{md_path}')
|
124
114
|
|
125
115
|
|
126
|
-
def parse_range(
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
return list(range(start, end + 1))
|
131
|
-
else:
|
132
|
-
raise ValueError('数字の範囲の形式が間違っています')
|
116
|
+
def parse_range(match: re.Match) -> List[int]:
|
117
|
+
start, end = map(int, match.groups())
|
118
|
+
start, end = min(start, end), max(start, end)
|
119
|
+
return list(range(start, end + 1))
|
133
120
|
|
134
121
|
|
135
|
-
def parse_diff_range(
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
end_index = ord(end) - ord('A')
|
141
|
-
if start_index <= end_index:
|
142
|
-
return [Diff(chr(i + ord('A'))) for i in range(start_index, end_index + 1)]
|
143
|
-
raise ValueError('A..C の形式になっていません')
|
122
|
+
def parse_diff_range(match: re.Match) -> List[Diff]:
|
123
|
+
start, end = match.groups()
|
124
|
+
start_index = min(ord(start.upper()), ord(end.upper()))
|
125
|
+
end_index = max(ord(start.upper()), ord(end.upper()))
|
126
|
+
return [Diff(chr(i)) for i in range(start_index, end_index + 1)]
|
144
127
|
|
145
128
|
|
146
129
|
def convert_arg(arg: str) -> Union[List[int], List[Diff]]:
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
return parse_diff_range(arg)
|
158
|
-
raise ValueError(f'{arg}は認識できません')
|
130
|
+
if arg.isdigit():
|
131
|
+
return [int(arg)]
|
132
|
+
elif arg.isalpha() and len(arg) == 1:
|
133
|
+
return [Diff(arg)]
|
134
|
+
elif match := re.match(r'^(\d+)\.\.(\d+)$', arg):
|
135
|
+
return parse_range(match)
|
136
|
+
elif match := re.match(r'^([A-Z])\.\.([A-Z])$', arg, re.IGNORECASE):
|
137
|
+
return parse_diff_range(match)
|
138
|
+
else:
|
139
|
+
raise ValueError(f'{arg}は認識できません')
|
159
140
|
|
160
141
|
|
161
142
|
def are_all_integers(args: Union[List[int], List[Diff]]) -> bool:
|
162
|
-
|
143
|
+
return all(isinstance(arg, int) for arg in args)
|
163
144
|
|
164
145
|
|
165
146
|
def are_all_diffs(args: Union[List[int], List[Diff]]) -> bool:
|
166
|
-
|
147
|
+
return all(isinstance(arg, Diff) for arg in args)
|
167
148
|
|
168
149
|
|
169
150
|
def interactive_download() -> None:
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
151
|
+
CONTEST = '1. コンテストの問題を解きたい'
|
152
|
+
PRACTICE = '2. 特定の難易度の問題を集中的に練習したい'
|
153
|
+
ONE_FILE = '3. 1問だけダウンロードする'
|
154
|
+
END = '4. 終了する'
|
155
|
+
|
156
|
+
choice = q.select(
|
157
|
+
message='AtCoderの問題のHTMLファイルをダウンロードします',
|
158
|
+
qmark='',
|
159
|
+
pointer='❯❯❯',
|
160
|
+
choices=[CONTEST, PRACTICE, ONE_FILE, END],
|
161
|
+
instruction='\n 十字キーで移動,[enter]で実行',
|
162
|
+
style=q.Style(
|
163
|
+
[
|
164
|
+
('question', 'fg:#2196F3 bold'),
|
165
|
+
('answer', 'fg:#FFB300 bold'),
|
166
|
+
('pointer', 'fg:#FFB300 bold'),
|
167
|
+
('highlighted', 'fg:#FFB300 bold'),
|
168
|
+
('selected', 'fg:#FFB300 bold'),
|
169
|
+
]
|
170
|
+
),
|
171
|
+
).ask()
|
172
|
+
|
173
|
+
if choice == CONTEST:
|
174
|
+
name = Prompt.ask(
|
175
|
+
'コンテスト名を入力してください (例: abc012, abs, typical90)',
|
176
|
+
)
|
177
|
+
|
178
|
+
problems = Contest(name=name).problems(session=load_session())
|
179
|
+
if not problems:
|
180
|
+
print(f'[red][Error][/red] コンテスト名が間違っています: {name}')
|
181
|
+
return
|
182
|
+
|
183
|
+
generate_problem_directory('.', problems, GenerateMode.gene_path_on_num)
|
184
|
+
|
185
|
+
elif choice == PRACTICE:
|
186
|
+
difficulty = Prompt.ask(
|
187
|
+
'難易度を入力してください (例: A)',
|
188
|
+
)
|
189
|
+
try:
|
190
|
+
difficulty = Diff(difficulty)
|
191
|
+
except KeyError:
|
192
|
+
raise ValueError('入力された難易度が認識できません')
|
193
|
+
number_str = Prompt.ask(
|
194
|
+
'コンテスト番号または範囲を入力してください (例: 120..130)'
|
195
|
+
)
|
196
|
+
if number_str.isdigit():
|
197
|
+
contest_numbers = [int(number_str)]
|
198
|
+
elif match := re.match(r'^\d+\.\.\d+$', number_str):
|
199
|
+
contest_numbers = parse_range(match)
|
200
|
+
else:
|
201
|
+
raise ValueError('数字の範囲の形式が間違っています')
|
202
|
+
|
203
|
+
problems = [
|
204
|
+
Problem(contest=Contest('abc', number), difficulty=difficulty)
|
205
|
+
for number in contest_numbers
|
206
|
+
]
|
207
|
+
|
208
|
+
generate_problem_directory('.', problems, GenerateMode.gene_path_on_diff)
|
209
|
+
|
210
|
+
elif choice == ONE_FILE:
|
211
|
+
name = Prompt.ask(
|
212
|
+
'コンテスト名を入力してください (例: abc012, abs, typical90)',
|
213
|
+
)
|
214
|
+
|
215
|
+
problems = Contest(name=name).problems(session=load_session())
|
216
|
+
|
217
|
+
problem = q.select(
|
218
|
+
message='どの問題をダウンロードしますか?',
|
219
|
+
qmark='',
|
220
|
+
pointer='❯❯❯',
|
221
|
+
choices=[
|
222
|
+
q.Choice(title=f'{problem.label:10} | {problem.url}', value=problem)
|
223
|
+
for problem in problems
|
224
|
+
],
|
225
|
+
instruction='\n 十字キーで移動,[enter]で実行',
|
226
|
+
style=q.Style(
|
227
|
+
[
|
228
|
+
('question', 'fg:#2196F3 bold'),
|
229
|
+
('answer', 'fg:#FFB300 bold'),
|
230
|
+
('pointer', 'fg:#FFB300 bold'),
|
231
|
+
('highlighted', 'fg:#FFB300 bold'),
|
232
|
+
('selected', 'fg:#FFB300 bold'),
|
233
|
+
]
|
234
|
+
),
|
235
|
+
).ask()
|
236
|
+
|
237
|
+
generate_problem_directory('.', [problem], GenerateMode.gene_path_on_num)
|
238
|
+
|
239
|
+
elif choice == END:
|
240
|
+
print('[bold red]終了します[/]')
|
241
|
+
else:
|
242
|
+
print('[bold red]無効な選択です[/]')
|
241
243
|
|
242
244
|
|
243
245
|
def download(
|
244
|
-
|
245
|
-
|
246
|
-
|
246
|
+
first: Union[str, int, None] = None,
|
247
|
+
second: Union[str, int, None] = None,
|
248
|
+
base_path: str = '.',
|
247
249
|
) -> None:
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
250
|
+
if first is None:
|
251
|
+
interactive_download()
|
252
|
+
return
|
253
|
+
|
254
|
+
if second is None:
|
255
|
+
try:
|
256
|
+
first_args = convert_arg(str(first))
|
257
|
+
except ValueError:
|
258
|
+
first = str(first)
|
259
|
+
problems = Contest(name=first).problems(session=load_session())
|
260
|
+
if not problems:
|
261
|
+
print(f'[red][Error][red/] コンテスト名が間違っています: {first}')
|
262
|
+
return
|
263
|
+
generate_problem_directory('.', problems, GenerateMode.gene_path_on_num)
|
264
|
+
return
|
265
|
+
|
266
|
+
if are_all_diffs(first_args):
|
267
|
+
raise ValueError(
|
268
|
+
"""難易度だけでなく, 問題番号も指定してコマンドを実行してください.
|
257
269
|
例 atcdr -d A 120 : A問題の120をダウンロードます
|
258
270
|
例 atcdr -d A 120..130 : A問題の120から130をダウンロードます
|
259
271
|
"""
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
"""次のような形式で問題を指定してください
|
272
|
+
)
|
273
|
+
else:
|
274
|
+
second_args = convert_arg(str(second))
|
275
|
+
|
276
|
+
if are_all_integers(first_args) and are_all_diffs(second_args):
|
277
|
+
first_args_int = cast(List[int], first_args)
|
278
|
+
second_args_diff = cast(List[Diff], second_args)
|
279
|
+
problems = [
|
280
|
+
Problem(Contest('abc', number), difficulty=diff)
|
281
|
+
for number in first_args_int
|
282
|
+
for diff in second_args_diff
|
283
|
+
]
|
284
|
+
generate_problem_directory(base_path, problems, GenerateMode.gene_path_on_num)
|
285
|
+
elif are_all_diffs(first_args) and are_all_integers(second_args):
|
286
|
+
first_args_diff = cast(List[Diff], first_args)
|
287
|
+
second_args_int = cast(List[int], second_args)
|
288
|
+
problems = [
|
289
|
+
Problem(Contest('abc', number), difficulty=diff)
|
290
|
+
for diff in first_args_diff
|
291
|
+
for number in second_args_int
|
292
|
+
]
|
293
|
+
generate_problem_directory(base_path, problems, GenerateMode.gene_path_on_diff)
|
294
|
+
else:
|
295
|
+
raise ValueError(
|
296
|
+
"""次のような形式で問題を指定してください
|
286
297
|
例 atcdr -d A 120..130 : A問題の120から130をダウンロードします
|
287
298
|
例 atcdr -d 120 : ABCのコンテストの問題をダウンロードします
|
288
299
|
"""
|
289
|
-
|
300
|
+
)
|