AtCoderStudyBooster 0.4.0__py3-none-any.whl → 0.4.2__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/ai.py ADDED
@@ -0,0 +1,434 @@
1
+ import json
2
+ import random
3
+ import time
4
+ from pathlib import Path
5
+ from typing import Any, Callable, Dict, List, Optional
6
+
7
+ import rich_click as click
8
+ from openai import BadRequestError, OpenAI
9
+ from rich.console import Console
10
+ from rich.live import Live
11
+ from rich.markup import escape
12
+ from rich.panel import Panel
13
+ from rich.syntax import Syntax
14
+ from rich.table import Table
15
+ from rich.text import Text
16
+
17
+ from atcdr.test import (
18
+ LabeledTestCase,
19
+ ResultStatus,
20
+ TestCase,
21
+ TestRunner,
22
+ )
23
+ from atcdr.util.fileops import add_file_selector
24
+ from atcdr.util.filetype import (
25
+ COMPILED_LANGUAGES,
26
+ FILE_EXTENSIONS,
27
+ INTERPRETED_LANGUAGES,
28
+ Lang,
29
+ str2lang,
30
+ )
31
+ from atcdr.util.i18n import _
32
+ from atcdr.util.openai import set_api_key
33
+ from atcdr.util.parse import ProblemHTML
34
+
35
+
36
+ def render_result_for_GPT(test: TestRunner) -> tuple[str, bool]:
37
+ results = list(test)
38
+ match test.info.summary:
39
+ case ResultStatus.CE:
40
+ return f'Compile Error \n {test.info.compiler_message}', False
41
+ case _:
42
+ message_for_gpt = ''.join(
43
+ (
44
+ f'\n{r.label} => {r.result.passed.value}, Execution Time : {r.result.executed_time}\n'
45
+ f'\nInput :\n{r.testcase.input}\nOutput :\n{r.result.output}\nExpected :\n{r.testcase.output}\n'
46
+ if r.result.passed == ResultStatus.WA
47
+ else f'\n{r.label} => {r.result.passed.value}\nInput :\n{r.testcase.input}\nOutput :\n{r.result.output}\n'
48
+ )
49
+ for r in results
50
+ )
51
+ return message_for_gpt, False
52
+
53
+
54
+ def display_test_results(console: Console, test: TestRunner) -> None:
55
+ results = list(test)
56
+
57
+ table = Table(title='🧪 Test Results')
58
+ table.add_column('Test Case', style='cyan', no_wrap=True)
59
+ table.add_column('Status', justify='center', no_wrap=True)
60
+ table.add_column('Input', style='dim', max_width=30)
61
+ table.add_column('Output', style='yellow', max_width=30)
62
+ table.add_column('Expected', style='green', max_width=30)
63
+
64
+ for r in results:
65
+ if r.result.passed == ResultStatus.AC:
66
+ status = '[green]✅ AC[/green]'
67
+ elif r.result.passed == ResultStatus.WA:
68
+ status = '[red]❌ WA[/red]'
69
+ elif r.result.passed == ResultStatus.TLE:
70
+ status = '[yellow]⏰ TLE[/yellow]'
71
+ elif r.result.passed == ResultStatus.RE:
72
+ status = '[red]💥 RE[/red]'
73
+ else:
74
+ status = f'[red]{r.result.passed.value}[/red]'
75
+
76
+ input_preview = escape(
77
+ r.testcase.input.strip()[:50] + '...'
78
+ if len(r.testcase.input.strip()) > 50
79
+ else r.testcase.input.strip()
80
+ )
81
+ output_preview = escape(
82
+ r.result.output.strip()[:50] + '...'
83
+ if len(r.result.output.strip()) > 50
84
+ else r.result.output.strip()
85
+ )
86
+ expected_preview = escape(
87
+ r.testcase.output.strip()[:50] + '...'
88
+ if len(r.testcase.output.strip()) > 50
89
+ else r.testcase.output.strip()
90
+ )
91
+
92
+ table.add_row(r.label, status, input_preview, output_preview, expected_preview)
93
+
94
+ console.print(table)
95
+
96
+
97
+ def create_func(labeled_cases: list[LabeledTestCase], model: str):
98
+ def test_example_case(code: str, language: str) -> str:
99
+ language_enum: Lang = str2lang(language)
100
+ source_path = Path(f'{model}{FILE_EXTENSIONS[language_enum]}')
101
+ source_path.write_text(code, encoding='utf-8')
102
+ test = TestRunner(str(source_path), labeled_cases)
103
+ message_for_gpt, _ = render_result_for_GPT(test)
104
+ return message_for_gpt
105
+
106
+ def execute_code(input: Optional[str], code: str, language: str) -> str:
107
+ language_enum: Lang = str2lang(language)
108
+ random_name = random.randint(0, 100_000_000)
109
+ source_path = Path(f'tmp{random_name}{FILE_EXTENSIONS[language_enum]}')
110
+ source_path.write_text(code, encoding='utf-8')
111
+ labeled_cases = [LabeledTestCase('case by gpt', TestCase(input or '', ''))]
112
+ test = TestRunner(str(source_path), labeled_cases)
113
+ labeled_result = next(test)
114
+ source_path.unlink(missing_ok=True)
115
+ return labeled_result.result.output
116
+
117
+ return test_example_case, execute_code
118
+
119
+
120
+ def solve_problem(path: Path, lang: Lang, model: str) -> None:
121
+ console = Console()
122
+ content = path.read_text(encoding='utf-8')
123
+ html = ProblemHTML(content)
124
+ md = html.make_problem_markdown('en')
125
+ labeled_cases = html.load_labeled_testcase()
126
+
127
+ test_example_case, execute_code = create_func(labeled_cases, model)
128
+
129
+ # Responses API 形式のツール定義(トップレベル)
130
+ TOOLS = [
131
+ {
132
+ 'type': 'function',
133
+ 'name': 'test_example_case',
134
+ 'description': 'Run the given source code against example test cases and return a summarized result.',
135
+ 'parameters': {
136
+ 'type': 'object',
137
+ 'properties': {
138
+ 'code': {'type': 'string'},
139
+ 'language': {
140
+ 'type': 'string',
141
+ 'enum': [
142
+ lang.value
143
+ for lang in (COMPILED_LANGUAGES + INTERPRETED_LANGUAGES)
144
+ ],
145
+ },
146
+ },
147
+ 'required': ['code', 'language'],
148
+ 'additionalProperties': False,
149
+ },
150
+ 'strict': True,
151
+ },
152
+ {
153
+ 'type': 'function',
154
+ 'name': 'execute_code',
155
+ 'description': 'Execute the given source code with a single input and return the actual output.',
156
+ 'parameters': {
157
+ 'type': 'object',
158
+ 'properties': {
159
+ 'input': {'type': 'string'},
160
+ 'code': {'type': 'string'},
161
+ 'language': {
162
+ 'type': 'string',
163
+ 'enum': [
164
+ lang.value
165
+ for lang in (COMPILED_LANGUAGES + INTERPRETED_LANGUAGES)
166
+ ],
167
+ },
168
+ },
169
+ 'required': ['input', 'code', 'language'],
170
+ 'additionalProperties': False,
171
+ },
172
+ 'strict': True,
173
+ },
174
+ ]
175
+
176
+ client = OpenAI()
177
+ if set_api_key() is None:
178
+ console.print('[red]OpenAI API key is not set.[/red]')
179
+ return
180
+
181
+ system_prompt = f"""You are a competitive programming assistant for {lang.value}.
182
+ The user will provide problems in Markdown format.
183
+ Read the problem carefully and output a complete, correct, and efficient solution in {lang.value}.
184
+ Use standard input and output. Do not omit any code.
185
+ Always pay close attention to algorithmic complexity (time and space).
186
+ Choose the most optimal algorithms and data structures so that the solution runs within time limits even for the largest possible inputs.
187
+
188
+ Use the provided tool test_example_case to run the example test cases from the problem statement.
189
+ If tests do not pass, fix the code and repeat.
190
+ The last tested code will be automatically saved to a local file on the user's computer.
191
+ You do not need to include the final source code in your response.
192
+ Simply confirm to the user that all tests passed, or briefly explain if they did not.
193
+ Once you run test_example_case, the exact code you tested will already be saved locally on the user's machine, so sending it again in the response is unnecessary."""
194
+
195
+ # ツール名→ローカル実装のディスパッチ
196
+ tool_impl: Dict[str, Callable[..., Any]] = {
197
+ 'test_example_case': test_example_case,
198
+ 'execute_code': lambda **p: execute_code(
199
+ p.get('input', ''), # ← 空なら空文字に
200
+ p.get('code', ''),
201
+ p.get('language', lang.value),
202
+ ),
203
+ }
204
+
205
+ console.print(f'Solving :{path} Language: {lang.value} / Model: {model}')
206
+
207
+ context_msgs = [
208
+ {'role': 'system', 'content': system_prompt},
209
+ {'role': 'user', 'content': md},
210
+ ]
211
+ turn = 1
212
+ assistant_text = Text()
213
+
214
+ def call_model():
215
+ try:
216
+ return client.responses.create(
217
+ model=model,
218
+ input=context_msgs,
219
+ tools=TOOLS,
220
+ tool_choice='auto',
221
+ include=['reasoning.encrypted_content'],
222
+ store=False,
223
+ )
224
+ except BadRequestError as e:
225
+ body = getattr(getattr(e, 'response', None), 'json', lambda: None)()
226
+ console.print(
227
+ Panel.fit(f'{e}\n\n{body}', title='API Error', border_style='red')
228
+ )
229
+ raise
230
+
231
+ while True:
232
+ start_time = time.time()
233
+ with Live(
234
+ Panel(
235
+ f'[bold blue]🤔 Thinking... (turn {turn})[/bold blue]\n[dim]Elapsed: 0.0s[/dim]',
236
+ border_style='blue',
237
+ ),
238
+ console=console,
239
+ refresh_per_second=10,
240
+ ) as live:
241
+
242
+ def update_timer():
243
+ elapsed = time.time() - start_time
244
+ live.update(
245
+ Panel(
246
+ f'[bold blue]🤔 Thinking... (turn {turn})[/bold blue]\n[dim]Elapsed: {elapsed:.1f}s[/dim]',
247
+ border_style='blue',
248
+ )
249
+ )
250
+
251
+ import threading
252
+
253
+ resp = None
254
+ error = None
255
+
256
+ def model_call():
257
+ nonlocal resp, error
258
+ try:
259
+ resp = call_model()
260
+ except Exception as e:
261
+ error = e
262
+
263
+ thread = threading.Thread(target=model_call)
264
+ thread.start()
265
+
266
+ while thread.is_alive():
267
+ update_timer()
268
+ time.sleep(0.1)
269
+
270
+ thread.join()
271
+
272
+ if error:
273
+ raise error
274
+
275
+ elapsed = time.time() - start_time
276
+ live.update(
277
+ Panel(
278
+ f'[bold green]✓ Completed thinking (turn {turn})[/bold green]\n[dim]Time taken: {elapsed:.1f}s[/dim]',
279
+ border_style='green',
280
+ )
281
+ )
282
+
283
+ # Display token usage
284
+ if resp and hasattr(resp, 'usage') and resp.usage:
285
+ usage = resp.usage
286
+ input_tokens = getattr(usage, 'input_tokens', 0)
287
+ output_tokens = getattr(usage, 'output_tokens', 0)
288
+ total_tokens = getattr(usage, 'total_tokens', 0)
289
+
290
+ # Check for cached tokens
291
+ cached_tokens = 0
292
+ if hasattr(usage, 'input_tokens_details'):
293
+ details = usage.input_tokens_details
294
+ if hasattr(details, 'cached_tokens'):
295
+ cached_tokens = details.cached_tokens
296
+
297
+ # Check for reasoning tokens
298
+ reasoning_tokens = 0
299
+ if hasattr(usage, 'output_tokens_details'):
300
+ details = usage.output_tokens_details
301
+ if hasattr(details, 'reasoning_tokens'):
302
+ reasoning_tokens = details.reasoning_tokens
303
+
304
+ token_msg = f'[dim]Tokens - Input: {input_tokens:,}'
305
+ if cached_tokens > 0:
306
+ token_msg += f' (cached: {cached_tokens:,})'
307
+ token_msg += f' | Output: {output_tokens:,}'
308
+ if reasoning_tokens > 0:
309
+ token_msg += f' (reasoning: {reasoning_tokens:,})'
310
+ token_msg += f' | Total: {total_tokens:,}[/dim]'
311
+ console.print(token_msg)
312
+
313
+ if resp and getattr(resp, 'output_text', None):
314
+ assistant_text.append(resp.output_text)
315
+
316
+ output_content = str(resp.output_text).strip()
317
+ if any(
318
+ keyword in output_content
319
+ for keyword in [
320
+ 'def ',
321
+ 'class ',
322
+ 'import ',
323
+ 'from ',
324
+ '#include',
325
+ 'public class',
326
+ ]
327
+ ):
328
+ try:
329
+ syntax = Syntax(
330
+ output_content, lang, theme='monokai', line_numbers=True
331
+ )
332
+ console.print(
333
+ Panel(
334
+ syntax,
335
+ title='Assistant Output (Code)',
336
+ border_style='green',
337
+ )
338
+ )
339
+ except Exception:
340
+ console.print(
341
+ Panel(
342
+ assistant_text,
343
+ title='Assistant Output',
344
+ border_style='green',
345
+ )
346
+ )
347
+ else:
348
+ console.print(
349
+ Panel(
350
+ assistant_text, title='Assistant Output', border_style='green'
351
+ )
352
+ )
353
+
354
+ if resp and hasattr(resp, 'output'):
355
+ context_msgs += resp.output
356
+
357
+ # function_call を収集
358
+ calls: List[dict] = []
359
+ for o in resp.output:
360
+ if getattr(o, 'type', '') == 'function_call':
361
+ try:
362
+ args = json.loads(o.arguments or '{}')
363
+ except Exception:
364
+ args = {}
365
+ call_id = getattr(o, 'call_id', None) or getattr(
366
+ o, 'id'
367
+ ) # ★ ここがポイント
368
+ calls.append({'name': o.name, 'call_id': call_id, 'args': args})
369
+ else:
370
+ calls = []
371
+
372
+ if not calls:
373
+ console.print(
374
+ Panel.fit('✅ Done (no more tool calls).', border_style='green')
375
+ )
376
+ break
377
+
378
+ # ツールを実行し、function_call_output を context に積む
379
+ for c in calls:
380
+ args_str = json.dumps(c['args'], ensure_ascii=False) if c['args'] else ''
381
+ console.print(
382
+ Panel.fit(
383
+ f"Tool: [bold]{c['name']}[/bold]\nargs: {args_str}",
384
+ title=f"function_call ({c['call_id']})",
385
+ border_style='cyan',
386
+ )
387
+ )
388
+
389
+ impl = tool_impl.get(c['name'])
390
+ if not impl:
391
+ out = f"[ERROR] Unknown tool: {c['name']}"
392
+ else:
393
+ try:
394
+ with console.status(f"Running {c['name']}...", spinner='dots'):
395
+ out = impl(**c['args']) if c['args'] else impl()
396
+ except TypeError:
397
+ out = impl(
398
+ **{
399
+ k: v
400
+ for k, v in c['args'].items()
401
+ if k in impl.__code__.co_varnames
402
+ }
403
+ )
404
+ except Exception as e:
405
+ out = f"[Tool '{c['name']}' error] {e}"
406
+
407
+ console.print(
408
+ Panel(
409
+ str(out) or '(no output)',
410
+ title=f"{c['name']} result",
411
+ border_style='magenta',
412
+ )
413
+ )
414
+
415
+ context_msgs.append(
416
+ {
417
+ 'type': 'function_call_output',
418
+ 'call_id': c['call_id'],
419
+ 'output': str(out),
420
+ }
421
+ )
422
+
423
+ turn += 1
424
+
425
+
426
+ @click.command(short_help=_('cmd_generate'), help=_('cmd_generate'))
427
+ @add_file_selector('files', filetypes=[Lang.HTML])
428
+ @click.option('--lang', default='Python', help=_('opt_output_lang'))
429
+ @click.option('--model', default='gpt-5-mini', help=_('opt_model'))
430
+ def ai(files, lang, model):
431
+ """HTMLファイルからコード生成またはテンプレート出力を行います。"""
432
+ lang_enum: Lang = str2lang(lang)
433
+ for path in files:
434
+ solve_problem(Path(path), lang_enum, model)
atcdr/cli.py CHANGED
@@ -8,8 +8,8 @@ from rich.table import Table
8
8
  from rich.traceback import install
9
9
  from rich_click import RichGroup
10
10
 
11
+ from atcdr.ai import ai
11
12
  from atcdr.download import download
12
- from atcdr.generate import generate
13
13
  from atcdr.login import login
14
14
  from atcdr.logout import logout
15
15
  from atcdr.markdown import markdown
@@ -76,7 +76,7 @@ def cli():
76
76
  cli.add_command(test, aliases=['t'])
77
77
  cli.add_command(download, aliases=['d'])
78
78
  cli.add_command(open_files, 'open', aliases=['o'])
79
- cli.add_command(generate, aliases=['g'])
79
+ cli.add_command(ai)
80
80
  cli.add_command(markdown, aliases=['md'])
81
81
  cli.add_command(submit, aliases=['s'])
82
82
  cli.add_command(login)
atcdr/download.py CHANGED
@@ -11,6 +11,7 @@ from rich import print
11
11
  from rich.prompt import Prompt
12
12
 
13
13
  from atcdr.util.filetype import FILE_EXTENSIONS, Lang
14
+ from atcdr.util.i18n import _, i18n
14
15
  from atcdr.util.parse import ProblemHTML
15
16
  from atcdr.util.problem import Contest, Problem
16
17
  from atcdr.util.session import load_session
@@ -25,32 +26,37 @@ class Downloader:
25
26
  retry_attempts = 3
26
27
  retry_wait = 1 # 1 second
27
28
 
28
- for _ in range(retry_attempts):
29
+ for attempt in range(retry_attempts):
29
30
  response = session.get(problem.url)
30
31
  if response.status_code == 200:
31
32
  return ProblemHTML(response.text)
32
33
  elif response.status_code == 429:
33
34
  print(
34
- f'[bold yellow][Error {response.status_code}][/bold yellow] 再試行します。{problem}'
35
+ f'[bold yellow][Error {response.status_code}][/bold yellow] '
36
+ + _('retry_problem', problem)
35
37
  )
36
38
  time.sleep(retry_wait)
37
39
  elif 300 <= response.status_code < 400:
38
40
  print(
39
- f'[bold yellow][Error {response.status_code}][/bold yellow] リダイレクトが発生しました。{problem}'
41
+ f'[bold yellow][Error {response.status_code}][/bold yellow] '
42
+ + _('redirect_occurred', problem)
40
43
  )
41
44
  elif 400 <= response.status_code < 500:
42
45
  print(
43
- f'[bold red][Error {response.status_code}][/bold red] 問題が見つかりません。{problem}'
46
+ f'[bold red][Error {response.status_code}][/bold red] '
47
+ + _('problem_not_found', problem)
44
48
  )
45
49
  break
46
50
  elif 500 <= response.status_code < 600:
47
51
  print(
48
- f'[bold red][Error {response.status_code}][/bold red] サーバーエラーが発生しました。{problem}'
52
+ f'[bold red][Error {response.status_code}][/bold red] '
53
+ + _('server_error', problem)
49
54
  )
50
55
  break
51
56
  else:
52
57
  print(
53
- f'[bold red][Error {response.status_code}][/bold red] {problem}に対応するHTMLファイルを取得できませんでした。'
58
+ f'[bold red][Error {response.status_code}][/bold red] '
59
+ + _('html_fetch_failed', problem)
54
60
  )
55
61
  break
56
62
  return ProblemHTML('')
@@ -68,7 +74,7 @@ def save_problem(problem: Problem, path: Path, session: requests.Session) -> Non
68
74
  problem_content = downloader.get(problem)
69
75
 
70
76
  if not problem_content:
71
- print(f'[bold red][Error][/] {problem}の保存に失敗しました')
77
+ print('[bold red][Error][/] ' + _('save_failed', problem))
72
78
  return
73
79
 
74
80
  # ディレクトリ作成(pathをそのまま使用)
@@ -80,26 +86,26 @@ def save_problem(problem: Problem, path: Path, session: requests.Session) -> Non
80
86
  # HTMLファイル保存
81
87
  html_path = path / (title + FILE_EXTENSIONS[Lang.HTML])
82
88
  html_path.write_text(problem_content.html, encoding='utf-8')
83
- print(f'[bold green][+][/bold green] ファイルを保存しました: {html_path}')
89
+ print('[bold green][+][/bold green] ' + _('file_saved', html_path))
84
90
 
85
91
  # Markdownファイル保存
86
- md = problem_content.make_problem_markdown('ja')
92
+ md = problem_content.make_problem_markdown(i18n.language)
87
93
  md_path = path / (title + FILE_EXTENSIONS[Lang.MARKDOWN])
88
94
  md_path.write_text(md, encoding='utf-8')
89
- print(f'[bold green][+][/bold green] ファイルを保存しました: {md_path}')
95
+ print('[bold green][+][/bold green] ' + _('file_saved', md_path))
90
96
 
91
97
 
92
98
  def interactive_download(session) -> None:
93
- CONTEST = '1. コンテストの問題を解きたい'
94
- ONE_FILE = '2. 1問だけダウンロードする'
95
- END = '3. 終了する'
99
+ CONTEST = '1. ' + _('solve_contest_problems')
100
+ ONE_FILE = '2. ' + _('download_one_problem')
101
+ END = '3. ' + _('exit')
96
102
 
97
103
  choice = q.select(
98
- message='AtCoderの問題のHTMLファイルをダウンロードします',
104
+ message=_('download_atcoder_html'),
99
105
  qmark='',
100
106
  pointer='❯❯❯',
101
107
  choices=[CONTEST, ONE_FILE, END],
102
- instruction='\n 十字キーで移動,[enter]で実行',
108
+ instruction='\n ' + _('navigate_with_arrows'),
103
109
  style=q.Style(
104
110
  [
105
111
  ('question', 'fg:#2196F3 bold'),
@@ -112,7 +118,7 @@ def interactive_download(session) -> None:
112
118
  ).ask()
113
119
 
114
120
  if choice == CONTEST:
115
- name = Prompt.ask('コンテスト名を入力してください (例: abc012, abs, typical90)')
121
+ name = Prompt.ask(_('input_contest_name'))
116
122
  try:
117
123
  contest = Contest(name, session)
118
124
  for problem in contest.problems:
@@ -121,18 +127,18 @@ def interactive_download(session) -> None:
121
127
  print(f'[red][Error][/red] {e}')
122
128
 
123
129
  elif choice == ONE_FILE:
124
- name = Prompt.ask('コンテスト名を入力してください (例: abc012, abs, typical90)')
130
+ name = Prompt.ask(_('input_contest_name'))
125
131
  try:
126
132
  contest = Contest(name, session)
127
133
  problem = q.select(
128
- message='どの問題をダウンロードしますか?',
134
+ message=_('which_problem_download'),
129
135
  qmark='',
130
136
  pointer='❯❯❯',
131
137
  choices=[
132
138
  q.Choice(title=f'{p.label:10} | {p.url}', value=p)
133
139
  for p in contest.problems
134
140
  ],
135
- instruction='\n 十字キーで移動,[enter]で実行',
141
+ instruction='\n ' + _('navigate_with_arrows'),
136
142
  style=q.Style(
137
143
  [
138
144
  ('question', 'fg:#2196F3 bold'),
@@ -148,9 +154,9 @@ def interactive_download(session) -> None:
148
154
  print(f'[red][Error][/red] {e}')
149
155
 
150
156
  elif choice == END:
151
- print('[bold red]終了します[/]')
157
+ print('[bold red]' + _('exiting') + '[/]')
152
158
  else:
153
- print('[bold red]無効な選択です[/]')
159
+ print('[bold red]' + _('invalid_selection') + '[/]')
154
160
 
155
161
 
156
162
  def plan_download(
@@ -179,7 +185,7 @@ def plan_download(
179
185
  for problem in contest.problems
180
186
  ]
181
187
  else:
182
- raise ValueError('コンテスト名を指定してください')
188
+ raise ValueError(_('specify_contest_name'))
183
189
  elif len(groups) == 2:
184
190
  result = []
185
191
  for i, j in product(groups[0], groups[1]):
@@ -195,16 +201,15 @@ def plan_download(
195
201
  result.append((problem, Path(i) / j.name))
196
202
  return result
197
203
  else:
198
- raise ValueError('ダウンロードの引数が正しくありません')
204
+ raise ValueError(_('invalid_download_args'))
199
205
 
200
206
 
201
- @click.command(short_help='AtCoder の問題をダウンロード')
207
+ @click.command(short_help=_('cmd_download'), help=_('cmd_download'))
202
208
  @click.argument('args', nargs=-1)
203
209
  def download(args: List[str]) -> None:
204
210
  """
205
- 例:
206
- download abc{001..012} {A..C}
207
- download {A..E} abc{001..012}
211
+ download abc{001..012} {A..C}
212
+ download {A..E} abc{001..012}
208
213
  """
209
214
  session = load_session()
210
215