doc-code 0.1.0__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.
doc_code/cli.py ADDED
@@ -0,0 +1,593 @@
1
+ """Typer command-line interface for Doc Code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import sys
8
+ from contextlib import contextmanager
9
+ from dataclasses import dataclass, replace
10
+ from pathlib import Path
11
+ from time import perf_counter
12
+
13
+ import typer
14
+
15
+ from .ai import documentation_for
16
+ from .config import TEMPLATE, Settings, load
17
+ from .editor import (
18
+ PreparedFile,
19
+ apply,
20
+ can_insert_documentation,
21
+ prepare,
22
+ preview_documentation,
23
+ validation_command,
24
+ )
25
+ from .errors import (
26
+ AIProviderError,
27
+ AITimeoutError,
28
+ DocGubError,
29
+ InvalidAIResponseError,
30
+ NoEligibleFilesError,
31
+ )
32
+ from .git import GitRepo
33
+ from .scope import resolve
34
+ from .symbols import Documentation, Symbol, discover, needs_documentation, source_for_symbol
35
+
36
+ app = typer.Typer(add_completion=False, no_args_is_help=False)
37
+ config_app = typer.Typer(help="Create and inspect Doc Code configuration.", no_args_is_help=True)
38
+ MAX_AI_ATTEMPTS = 3
39
+ _DUPLICATE_SYMBOL_SUFFIX = re.compile(r"^(?P<name>.+)@L\d+:\d+$")
40
+
41
+
42
+ @contextmanager
43
+ def _loading(message: str):
44
+ """Show one stable progress line while the AI responds interactively."""
45
+ if not sys.stderr.isatty():
46
+ yield
47
+ return
48
+ lines = message.splitlines()
49
+ typer.secho(f"\r{message}", fg=typer.colors.CYAN, nl=False, err=True)
50
+ try:
51
+ yield
52
+ finally:
53
+ if len(lines) > 1:
54
+ # Move up for each extra line and clear from cursor to end of screen
55
+ typer.echo(f"\033[{len(lines) - 1}A\033[J", nl=False, err=True)
56
+ else:
57
+ typer.echo("\r" + " " * len(message) + "\r", nl=False, err=True)
58
+
59
+
60
+ def _show(item: PreparedFile, model: str, elapsed: float, show_diff: bool = False) -> None:
61
+ """Display a generation summary and optionally its unified diff."""
62
+ relative = (item.display_path or item.path).as_posix()
63
+ typer.echo()
64
+ typer.secho(relative, fg=typer.colors.CYAN, bold=True)
65
+ typer.echo(
66
+ f"Symbols: {len(item.symbols)} | changed: {len(item.changed)} | "
67
+ f"ignored: {len(item.ignored)}"
68
+ )
69
+ typer.echo(f"Model: {model} | Generated in {elapsed:.2f}s")
70
+ if item.diff:
71
+ typer.secho("Status: documentation changes generated.", fg=typer.colors.GREEN)
72
+ if show_diff:
73
+ typer.echo(item.diff, nl=not item.diff.endswith("\n"))
74
+ else:
75
+ typer.secho("No documentation changes needed.", fg=typer.colors.BRIGHT_BLACK)
76
+
77
+
78
+ def _show_skipped(relative: str, reason: str) -> None:
79
+ """Report a file-level generation failure without stopping the remaining scope."""
80
+ typer.echo()
81
+ typer.secho(f"Skipped documentation: {relative}", fg=typer.colors.YELLOW, bold=True)
82
+ typer.secho(f"Reason: {reason}")
83
+
84
+
85
+ def _show_check(missing: dict[str, list[str]]) -> None:
86
+ """Print the actionable output used by CI and local check runs."""
87
+ if not missing:
88
+ typer.secho("Documentation check passed.", fg=typer.colors.GREEN, bold=True)
89
+ return
90
+ typer.secho("Documentation is missing:", fg=typer.colors.YELLOW, bold=True)
91
+ for relative, symbols in missing.items():
92
+ typer.echo(f" {relative}: {', '.join(symbols)}")
93
+
94
+
95
+ def _model_for_attempt(candidates: tuple[str, ...], attempt: int) -> str:
96
+ """Cycle configured fallback models instead of pinning later retries to the last one."""
97
+ return candidates[(attempt - 1) % len(candidates)]
98
+
99
+
100
+ def _symbol_identity(symbol: Symbol) -> tuple[str, str]:
101
+ """Return a symbol identity that remains stable when inserted lines shift it."""
102
+ match = _DUPLICATE_SYMBOL_SUFFIX.fullmatch(symbol.name)
103
+ name = match.group("name") if match else symbol.name
104
+ return name, symbol.kind
105
+
106
+
107
+ def _symbol_occurrence(symbols: list[Symbol], target: Symbol) -> int | None:
108
+ """Return the ordinal needed to locate a duplicate symbol after an edit."""
109
+ matches = [symbol for symbol in symbols if _symbol_identity(symbol) == _symbol_identity(target)]
110
+ if len(matches) == 1:
111
+ return None
112
+ return matches.index(target)
113
+
114
+
115
+ def _undocumented_symbols(content: str, suffix: str, filename: str = "<unknown>") -> list[str]:
116
+ """Return symbols that make `--check` fail without requesting AI output."""
117
+ return [symbol.name for symbol in discover(content, suffix, filename) if not symbol.has_doc]
118
+
119
+
120
+ def _syntax_error_message(exc: SyntaxError) -> str:
121
+ """Render Python syntax errors with the file, source line, and error column."""
122
+ location = f"{exc.filename or '<unknown>'}:{exc.lineno or '?'}"
123
+ message = f"{location}: {exc.msg}"
124
+ if not exc.text:
125
+ return message
126
+ source = exc.text.rstrip("\n")
127
+ column = max((exc.offset or 1) - 1, 0)
128
+ return f"{message}\n {source}\n {' ' * column}^"
129
+
130
+
131
+ def _read_source(path: Path) -> str:
132
+ """Read UTF-8 source and convert filesystem failures to domain errors."""
133
+ try:
134
+ return path.read_text(encoding="utf-8")
135
+ except OSError as exc:
136
+ raise DocGubError(f"Unable to read source file {path}: {exc}") from exc
137
+
138
+
139
+ @dataclass
140
+ class _RunState:
141
+ """Track file-level results across an incremental run."""
142
+
143
+ completed: list[str]
144
+ skipped: list[str]
145
+
146
+ def mark_completed(self, relative: str) -> None:
147
+ """Record an applied file once."""
148
+ if relative not in self.completed:
149
+ self.completed.append(relative)
150
+
151
+ def mark_skipped(self, relative: str, reason: str) -> None:
152
+ """Record and display a file-level failure."""
153
+ self.skipped.append(relative)
154
+ _show_skipped(relative, reason)
155
+
156
+
157
+ def _run_check(repo: GitRepo, files: list[str]) -> None:
158
+ """Inspect selected files without calling an AI provider."""
159
+ missing: dict[str, list[str]] = {}
160
+ inspection_failures: list[str] = []
161
+ for relative in files:
162
+ try:
163
+ content = _read_source(repo.root / relative)
164
+ undocumented = _undocumented_symbols(content, Path(relative).suffix, relative)
165
+ except (DocGubError, UnicodeDecodeError, SyntaxError) as exc:
166
+ inspection_failures.append(relative)
167
+ reason = _syntax_error_message(exc) if isinstance(exc, SyntaxError) else str(exc)
168
+ _show_skipped(relative, reason)
169
+ continue
170
+ if undocumented:
171
+ missing[relative] = undocumented
172
+ if not missing and not inspection_failures:
173
+ _show_check({})
174
+ return
175
+ if missing:
176
+ _show_check(missing)
177
+ if inspection_failures:
178
+ typer.secho(
179
+ f"Documentation check could not inspect {len(inspection_failures)} file(s).",
180
+ fg=typer.colors.RED,
181
+ )
182
+ raise typer.Exit(1)
183
+
184
+
185
+ def _require_interactive_confirmation(settings: Settings, yes: bool) -> None:
186
+ """Ensure per-docstring confirmation can be requested before generating output."""
187
+ if settings.output != "apply" or not settings.confirm or yes:
188
+ return
189
+ if not sys.stdin.isatty():
190
+ raise DocGubError(
191
+ "Confirmation requires an interactive terminal; use --yes for automation."
192
+ )
193
+
194
+
195
+ def _confirm_generated_docstring(relative: str, symbol: Symbol, documentation: str) -> bool:
196
+ """Show one rendered docstring and ask whether it should be written."""
197
+ if not sys.stdin.isatty():
198
+ raise DocGubError(
199
+ "Confirmation requires an interactive terminal; use --yes for automation."
200
+ )
201
+ typer.echo()
202
+ typer.secho(f"{relative}:{symbol.name}", fg=typer.colors.CYAN, bold=True)
203
+ typer.echo(documentation)
204
+ confirmed = typer.confirm("Apply this docstring?", default=False)
205
+ if not confirmed:
206
+ typer.secho(f"Not applied: {relative}:{symbol.name}", fg=typer.colors.YELLOW)
207
+ return confirmed
208
+
209
+
210
+ def _confirm_generated_file(item: PreparedFile) -> bool:
211
+ """Confirm a displayed generated diff immediately before applying it."""
212
+ if not sys.stdin.isatty():
213
+ raise DocGubError(
214
+ "Confirmation requires an interactive terminal; use --yes for automation."
215
+ )
216
+ relative = (item.display_path or item.path).as_posix()
217
+ confirmed = typer.confirm(f"Apply the generated documentation to {relative}?", default=False)
218
+ if not confirmed:
219
+ typer.secho(f"Not applied: {relative}", fg=typer.colors.YELLOW)
220
+ return confirmed
221
+
222
+
223
+ def _request_batches(
224
+ content: str,
225
+ targets: list[Symbol],
226
+ path: Path,
227
+ relative: str,
228
+ settings: Settings,
229
+ ) -> list[tuple[str, list[Symbol]]]:
230
+ """Build file- or symbol-scoped provider requests."""
231
+ if settings.request_scope == "file":
232
+ return [(content, targets)]
233
+ return [
234
+ (source_for_symbol(content, symbol, path.suffix, relative), [symbol]) for symbol in targets
235
+ ]
236
+
237
+
238
+ def _request_documentation(
239
+ source: str,
240
+ symbols: list[Symbol],
241
+ settings: Settings,
242
+ label: str,
243
+ ) -> tuple[dict[str, Documentation], str]:
244
+ """Request documentation with bounded, model-cycling retries."""
245
+ candidates = settings.model_candidates
246
+ last_error: Exception | None = None
247
+ for attempt in range(1, MAX_AI_ATTEMPTS + 1):
248
+ candidate = _model_for_attempt(candidates, attempt)
249
+ try:
250
+ with _loading(
251
+ f"Generating docs [{label}] | model: {candidate} ({attempt}/{MAX_AI_ATTEMPTS})..."
252
+ ):
253
+ generated = documentation_for(
254
+ source,
255
+ symbols,
256
+ replace(settings, model=candidate, models=()),
257
+ )
258
+ return generated, candidate
259
+ except (AIProviderError, InvalidAIResponseError) as exc:
260
+ last_error = exc
261
+ if attempt < MAX_AI_ATTEMPTS:
262
+ next_model = _model_for_attempt(candidates, attempt + 1)
263
+ reason = "AI request timed out" if isinstance(exc, AITimeoutError) else str(exc)
264
+ typer.secho(
265
+ f"{reason} with model [{candidate}]. Retrying with model "
266
+ f"[{next_model}] ({attempt + 1}/{MAX_AI_ATTEMPTS})...",
267
+ fg=typer.colors.YELLOW,
268
+ )
269
+ raise AIProviderError(f"generation failed after {MAX_AI_ATTEMPTS} attempts: {last_error}")
270
+
271
+
272
+ def _apply_generated_symbol(
273
+ file_path: Path,
274
+ relative: str,
275
+ target: Symbol,
276
+ generated: dict[str, Documentation],
277
+ settings: Settings,
278
+ occurrence: int | None = None,
279
+ confirm: bool = False,
280
+ ) -> bool:
281
+ """Apply one generated symbol against a freshly discovered source tree."""
282
+ current_symbols = discover(_read_source(file_path), file_path.suffix, relative)
283
+ if occurrence is None:
284
+ current_target = next(
285
+ (
286
+ symbol
287
+ for symbol in current_symbols
288
+ if symbol.name == target.name and symbol.kind == target.kind
289
+ ),
290
+ None,
291
+ )
292
+ else:
293
+ matches = [
294
+ symbol
295
+ for symbol in current_symbols
296
+ if _symbol_identity(symbol) == _symbol_identity(target)
297
+ ]
298
+ current_target = matches[occurrence] if occurrence < len(matches) else None
299
+ if current_target is None:
300
+ raise DocGubError(f"{relative}: symbol `{target.name}` changed during generation.")
301
+ documentation = generated.get(target.name)
302
+ if documentation is None:
303
+ raise DocGubError(f"{relative}: missing generated documentation for `{target.name}`.")
304
+ if confirm:
305
+ preview = preview_documentation(
306
+ file_path,
307
+ _read_source(file_path),
308
+ current_target,
309
+ documentation,
310
+ settings,
311
+ )
312
+ if not _confirm_generated_docstring(relative, current_target, preview):
313
+ return False
314
+ item = prepare(
315
+ file_path,
316
+ current_symbols,
317
+ {current_target.name: documentation},
318
+ settings,
319
+ selected_symbols=[current_target],
320
+ display_path=Path(relative),
321
+ )
322
+ if not item.diff:
323
+ return False
324
+ apply(item)
325
+ return True
326
+
327
+
328
+ def _generate_for_file(
329
+ file_path: Path,
330
+ relative: str,
331
+ content: str,
332
+ targets: list[Symbol],
333
+ settings: Settings,
334
+ state: _RunState,
335
+ apply_incrementally: bool,
336
+ confirm_each_docstring: bool,
337
+ ) -> tuple[dict[str, Documentation], str]:
338
+ """Generate every request for one file and optionally apply symbols incrementally."""
339
+ requests = _request_batches(content, targets, file_path, relative, settings)
340
+ descriptions: dict[str, Documentation] = {}
341
+ candidate = settings.model
342
+ for number, (source, requested_symbols) in enumerate(requests, start=1):
343
+ label = relative
344
+ if settings.request_scope == "symbol":
345
+ label = f"{number}/{len(requests)} {relative}:{requested_symbols[0].name}"
346
+ generated, candidate = _request_documentation(source, requested_symbols, settings, label)
347
+ descriptions.update(generated)
348
+ if (
349
+ apply_incrementally
350
+ and settings.request_scope == "symbol"
351
+ and settings.output == "apply"
352
+ ):
353
+ if _apply_generated_symbol(
354
+ file_path,
355
+ relative,
356
+ requested_symbols[0],
357
+ generated,
358
+ settings,
359
+ _symbol_occurrence(targets, requested_symbols[0]),
360
+ confirm=confirm_each_docstring,
361
+ ):
362
+ state.mark_completed(relative)
363
+ return descriptions, candidate
364
+
365
+
366
+ def _apply_reviewed_docstrings(
367
+ file_path: Path,
368
+ relative: str,
369
+ targets: list[Symbol],
370
+ descriptions: dict[str, Documentation],
371
+ settings: Settings,
372
+ state: _RunState,
373
+ ) -> None:
374
+ """Review and apply generated docstrings one at a time for a file-scoped request."""
375
+ for target in targets:
376
+ if _apply_generated_symbol(
377
+ file_path,
378
+ relative,
379
+ target,
380
+ descriptions,
381
+ settings,
382
+ _symbol_occurrence(targets, target),
383
+ confirm=True,
384
+ ):
385
+ state.mark_completed(relative)
386
+ typer.secho(f"Applied documentation: {relative}:{target.name}", fg=typer.colors.GREEN)
387
+
388
+
389
+ def _process_file(
390
+ repo: GitRepo,
391
+ relative: str,
392
+ settings: Settings,
393
+ show_diff: bool,
394
+ state: _RunState,
395
+ yes: bool,
396
+ ) -> None:
397
+ """Discover, generate, validate, and optionally apply documentation for one file."""
398
+ file_path = repo.root / relative
399
+ try:
400
+ content = _read_source(file_path)
401
+ symbols = discover(content, file_path.suffix, relative)
402
+ except (DocGubError, UnicodeDecodeError, SyntaxError) as exc:
403
+ reason = _syntax_error_message(exc) if isinstance(exc, SyntaxError) else str(exc)
404
+ state.mark_skipped(relative, reason)
405
+ return
406
+ targets = [
407
+ symbol
408
+ for symbol in symbols
409
+ if needs_documentation(symbol, settings.coverage)
410
+ and can_insert_documentation(content, symbol, file_path.suffix)
411
+ ]
412
+ if not targets:
413
+ item = prepare(file_path, symbols, {}, settings, display_path=Path(relative))
414
+ _show(item, "not used", 0, settings.output == "preview" and show_diff)
415
+ return
416
+ confirm_after_generation = settings.output == "apply" and not settings.confirm and not yes
417
+ confirm_each_docstring = settings.output == "apply" and settings.confirm and not yes
418
+ started = perf_counter()
419
+ try:
420
+ if file_path.suffix != ".py":
421
+ validation_command(file_path.suffix, file_path)
422
+ descriptions, candidate = _generate_for_file(
423
+ file_path,
424
+ relative,
425
+ content,
426
+ targets,
427
+ settings,
428
+ state,
429
+ apply_incrementally=not confirm_after_generation,
430
+ confirm_each_docstring=confirm_each_docstring,
431
+ )
432
+ if (
433
+ settings.request_scope == "symbol"
434
+ and settings.output == "apply"
435
+ and not confirm_after_generation
436
+ ):
437
+ return
438
+ if settings.output == "apply" and confirm_each_docstring:
439
+ _apply_reviewed_docstrings(file_path, relative, targets, descriptions, settings, state)
440
+ return
441
+ item = prepare(file_path, symbols, descriptions, settings, display_path=Path(relative))
442
+ _show(
443
+ item,
444
+ candidate,
445
+ perf_counter() - started,
446
+ (settings.output == "preview" and show_diff) or confirm_after_generation,
447
+ )
448
+ if settings.output == "apply" and item.diff:
449
+ if confirm_after_generation and not _confirm_generated_file(item):
450
+ return
451
+ apply(item)
452
+ state.mark_completed(relative)
453
+ typer.secho(f"Applied documentation: {relative}", fg=typer.colors.GREEN)
454
+ except DocGubError as exc:
455
+ state.mark_skipped(relative, str(exc))
456
+
457
+
458
+ def _finish_run(settings: Settings, state: _RunState, continue_on_error: bool) -> None:
459
+ """Display aggregate results and enforce the partial-failure exit policy."""
460
+ if settings.output == "apply":
461
+ typer.secho(
462
+ f"Documentation applied to {len(state.completed)} file(s).",
463
+ fg=typer.colors.GREEN,
464
+ bold=True,
465
+ )
466
+ if state.skipped:
467
+ typer.secho(f"Skipped files: {len(state.skipped)}", fg=typer.colors.YELLOW, bold=True)
468
+ if not continue_on_error:
469
+ raise typer.Exit(1)
470
+
471
+
472
+ @app.command()
473
+ def doc_code(
474
+ paths: list[Path] = typer.Argument(
475
+ None,
476
+ metavar="[PATH]...",
477
+ help="One or more files or directories inside the Git worktree.",
478
+ ),
479
+ output: str | None = typer.Option(None, "--output", help="preview (default) or apply."),
480
+ dry_run: bool = typer.Option(False, "--dry-run", help="Alias for --output preview."),
481
+ check: bool = typer.Option(
482
+ False,
483
+ "--check",
484
+ help="Exit with status 1 when eligible symbols are undocumented; never calls AI.",
485
+ ),
486
+ continue_on_error: bool = typer.Option(
487
+ False,
488
+ "--continue-on-error",
489
+ help="Return status 0 after partial failures when other files can still be processed.",
490
+ ),
491
+ show_diff: bool = typer.Option(
492
+ True,
493
+ "--show-diff/--no-show-diff",
494
+ help="Show generated unified diffs in preview mode.",
495
+ ),
496
+ yes: bool = typer.Option(
497
+ False, "--yes", "-y", help="Skip interactive confirmation when applying."
498
+ ),
499
+ coverage: str | None = typer.Option(None, "--coverage", help="missing, minimal, or all."),
500
+ request_scope: str | None = typer.Option(
501
+ None,
502
+ "--request-scope",
503
+ help="file (default) or symbol; symbol sends one source scope per request.",
504
+ ),
505
+ language: str | None = typer.Option(
506
+ None, "--language", help="Language used for generated documentation."
507
+ ),
508
+ selection: str | None = typer.Option(None, "--selection", help="changes or repository."),
509
+ python_format: str | None = typer.Option(
510
+ None, "--format", help="google, numpy, or sphinx for Python."
511
+ ),
512
+ provider: str | None = typer.Option(None, "--provider", help="openai, gemini, or ollama."),
513
+ model: str | None = typer.Option(None, "--model", help="Model name."),
514
+ timeout_seconds: int | None = typer.Option(None, "--timeout-seconds", min=1),
515
+ max_input_tokens: int | None = typer.Option(None, "--max-input-tokens", min=1),
516
+ context_window_tokens: int | None = typer.Option(None, "--context-window-tokens", min=1),
517
+ config: Path | None = typer.Option(None, "--config", help="Additional TOML configuration."),
518
+ ) -> None:
519
+ """Preview or safely apply AI-generated docs to Python, JavaScript and TypeScript."""
520
+ try:
521
+ repo = GitRepo()
522
+ settings = load(
523
+ repo.root,
524
+ config,
525
+ output="preview" if dry_run or check else output,
526
+ coverage=coverage,
527
+ request_scope=request_scope,
528
+ language=language,
529
+ selection=selection,
530
+ python_format=python_format,
531
+ provider=provider,
532
+ model=model,
533
+ timeout_seconds=timeout_seconds,
534
+ max_input_tokens=max_input_tokens,
535
+ context_window_tokens=context_window_tokens,
536
+ )
537
+ try:
538
+ files = resolve(repo, paths, settings)
539
+ except NoEligibleFilesError:
540
+ if check:
541
+ raise
542
+ typer.secho(
543
+ "Nothing to document: no eligible source files were found. "
544
+ "The selected scope may already be fully documented or contain no supported "
545
+ "changes.",
546
+ fg=typer.colors.GREEN,
547
+ )
548
+ return
549
+ if check:
550
+ _run_check(repo, files)
551
+ return
552
+ _require_interactive_confirmation(settings, yes)
553
+ state = _RunState([], [])
554
+ for relative in files:
555
+ _process_file(repo, relative, settings, show_diff, state, yes)
556
+ _finish_run(settings, state, continue_on_error)
557
+ except (DocGubError, UnicodeDecodeError, SyntaxError) as exc:
558
+ message = _syntax_error_message(exc) if isinstance(exc, SyntaxError) else str(exc)
559
+ typer.secho(f"Error: {message}", fg=typer.colors.RED, bold=True, err=True)
560
+ raise typer.Exit(1) from exc
561
+
562
+
563
+ @config_app.command("init")
564
+ def config_init(path: Path = typer.Option(Path(".doc-code.toml"), "--path")) -> None:
565
+ """Create a documentation configuration template without overwriting files."""
566
+ try:
567
+ if path.exists():
568
+ typer.secho(f"Error: {path} already exists.", fg=typer.colors.RED, err=True)
569
+ raise typer.Exit(1)
570
+ path.write_text(TEMPLATE, encoding="utf-8")
571
+ except OSError as exc:
572
+ typer.secho(f"Error: unable to create {path}: {exc}", fg=typer.colors.RED, err=True)
573
+ raise typer.Exit(1) from exc
574
+ typer.secho(f"Configuration created at {path}.", fg=typer.colors.GREEN)
575
+
576
+
577
+ @config_app.command("show")
578
+ def config_show(config: Path | None = typer.Option(None, "--config")) -> None:
579
+ """Print effective configuration without credentials."""
580
+ try:
581
+ repo = GitRepo()
582
+ typer.echo(json.dumps(load(repo.root, config).__dict__, ensure_ascii=False, indent=2))
583
+ except DocGubError as exc:
584
+ typer.secho(f"Error: {exc}", fg=typer.colors.RED, err=True)
585
+ raise typer.Exit(1) from exc
586
+
587
+
588
+ def main() -> None:
589
+ """Dispatch `config` separately so it never looks like a path argument."""
590
+ if len(sys.argv) > 1 and sys.argv[1] == "config":
591
+ config_app(args=sys.argv[2:], prog_name="doc-code config")
592
+ else:
593
+ app()