reviewpack 0.4.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.
reviewpack/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Reviewpack.
2
+
3
+ Privacy-first context packs for AI-assisted pull request review.
4
+ """
5
+
6
+ __version__ = "0.4.0"
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from reviewpack.models import ReviewpackResult
6
+ from reviewpack.redaction import redact_text
7
+
8
+
9
+ def render_ai_input_preview(result: ReviewpackResult) -> str:
10
+ """Render a local preview of AI-bound context.
11
+
12
+ This function does not call any AI provider. It only generates a local
13
+ Markdown file that shows the type of context that could be sent if a future
14
+ AI integration is explicitly enabled.
15
+ """
16
+
17
+ lines: list[str] = []
18
+
19
+ lines.append("# AI Input Preview")
20
+ lines.append("")
21
+ lines.append("This file is generated locally.")
22
+ lines.append("")
23
+ lines.append("It shows the context that may be sent to an AI provider if AI mode is enabled in a future version.")
24
+ lines.append("")
25
+ lines.append("Reviewpack does not send this content anywhere by default.")
26
+ lines.append("")
27
+ lines.append("## Included by default")
28
+ lines.append("")
29
+ lines.append("- Pull request title")
30
+ lines.append("- Pull request description")
31
+ lines.append("- Changed file paths")
32
+ lines.append("- Change statistics")
33
+ lines.append("- Deterministic risk signals")
34
+ lines.append("- Suggested review focus")
35
+ lines.append("")
36
+ lines.append("## Excluded by default")
37
+ lines.append("")
38
+ lines.append("- Raw diffs")
39
+ lines.append("- Full source code")
40
+ lines.append("- Branch names")
41
+ lines.append("- Commit messages")
42
+ lines.append("- Environment variables")
43
+ lines.append("- Terminal history")
44
+ lines.append("- Git remote URLs")
45
+ lines.append("- API tokens")
46
+ lines.append("")
47
+ lines.append("## Pull Request")
48
+ lines.append("")
49
+ lines.append(f"- Title: {redact_text(result.pr.title)}")
50
+ lines.append(f"- Author: {redact_text(result.pr.author)}")
51
+
52
+ if result.pr.url:
53
+ lines.append(f"- URL: {redact_text(result.pr.url)}")
54
+
55
+ if result.pr.description:
56
+ lines.append("")
57
+ lines.append("## Description")
58
+ lines.append("")
59
+ lines.append(redact_text(result.pr.description.strip()))
60
+
61
+ lines.append("")
62
+ lines.append("## Change Statistics")
63
+ lines.append("")
64
+ lines.append(f"- Files changed: {result.stats.files_changed}")
65
+ lines.append(f"- Lines added: {result.stats.additions}")
66
+ lines.append(f"- Lines deleted: {result.stats.deletions}")
67
+ lines.append(f"- Source files: {result.stats.source_files}")
68
+ lines.append(f"- Test files: {result.stats.test_files}")
69
+ lines.append(f"- Documentation files: {result.stats.docs_files}")
70
+ lines.append(f"- Dependency files: {result.stats.dependency_files}")
71
+ lines.append(f"- CI files: {result.stats.ci_files}")
72
+ lines.append(f"- Config files: {result.stats.config_files}")
73
+ lines.append(f"- Infrastructure files: {result.stats.infra_files}")
74
+ lines.append(f"- Unknown files: {result.stats.unknown_files}")
75
+
76
+ lines.append("")
77
+ lines.append("## Changed Files")
78
+ lines.append("")
79
+
80
+ for changed_file in result.changed_files:
81
+ lines.append(
82
+ f"- {redact_text(changed_file.path)} "
83
+ f"({changed_file.category.value}, +{changed_file.additions}/-{changed_file.deletions})"
84
+ )
85
+
86
+ lines.append("")
87
+ lines.append("## Risk Signals")
88
+ lines.append("")
89
+
90
+ if result.risk_signals:
91
+ for signal in result.risk_signals:
92
+ lines.append(f"- {signal.level.value}: {redact_text(signal.title)}")
93
+ lines.append(f" - {redact_text(signal.message)}")
94
+ if signal.files:
95
+ lines.append(" - Affected files:")
96
+ for file_path in signal.files:
97
+ lines.append(f" - {redact_text(file_path)}")
98
+ else:
99
+ lines.append("- No deterministic risk signals were detected.")
100
+
101
+ lines.append("")
102
+ lines.append("## Suggested Review Focus")
103
+ lines.append("")
104
+
105
+ for item in result.review_focus:
106
+ lines.append(f"- {redact_text(item.title)}")
107
+ lines.append(f" - {redact_text(item.reason)}")
108
+
109
+ lines.append("")
110
+ lines.append("## Safety Notes")
111
+ lines.append("")
112
+ lines.append("- This preview was generated without network access.")
113
+ lines.append("- This preview was generated without calling an AI provider.")
114
+ lines.append("- This preview does not include raw diffs or source code.")
115
+ lines.append("- This preview should be reviewed before being copied into any AI tool.")
116
+ lines.append("")
117
+
118
+ return "\n".join(lines)
119
+
120
+
121
+ def write_ai_input_preview(result: ReviewpackResult, output_dir: str | Path) -> Path:
122
+ """Write AI input preview Markdown to an output directory."""
123
+
124
+ target_dir = Path(output_dir)
125
+ target_dir.mkdir(parents=True, exist_ok=True)
126
+
127
+ output_path = target_dir / "ai-input-preview.md"
128
+ output_path.write_text(render_ai_input_preview(result), encoding="utf-8")
129
+
130
+ return output_path
reviewpack/analyzer.py ADDED
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+
5
+ from reviewpack.config import ReviewpackConfig
6
+ from reviewpack.models import (
7
+ ChangeStats,
8
+ ChangedFile,
9
+ FileCategory,
10
+ ReviewFocusItem,
11
+ ReviewpackInput,
12
+ ReviewpackResult,
13
+ )
14
+ from reviewpack.rules import categorize_file, generate_risk_signals
15
+
16
+
17
+ def categorize_changed_files(
18
+ files: list[ChangedFile],
19
+ config: ReviewpackConfig | None = None,
20
+ ) -> list[ChangedFile]:
21
+ """Return changed files with file categories populated."""
22
+
23
+ active_config = config or ReviewpackConfig()
24
+ categorized: list[ChangedFile] = []
25
+
26
+ for changed_file in files:
27
+ categorized.append(
28
+ changed_file.model_copy(
29
+ update={
30
+ "category": categorize_file(changed_file.path, active_config),
31
+ }
32
+ )
33
+ )
34
+
35
+ return categorized
36
+
37
+
38
+ def calculate_stats(files: list[ChangedFile]) -> ChangeStats:
39
+ """Calculate aggregate change statistics."""
40
+
41
+ category_counts = Counter(item.category for item in files)
42
+
43
+ return ChangeStats(
44
+ files_changed=len(files),
45
+ additions=sum(item.additions for item in files),
46
+ deletions=sum(item.deletions for item in files),
47
+ source_files=category_counts[FileCategory.SOURCE],
48
+ test_files=category_counts[FileCategory.TEST],
49
+ docs_files=category_counts[FileCategory.DOCS],
50
+ dependency_files=category_counts[FileCategory.DEPENDENCY],
51
+ ci_files=category_counts[FileCategory.CI],
52
+ config_files=category_counts[FileCategory.CONFIG],
53
+ infra_files=category_counts[FileCategory.INFRA],
54
+ unknown_files=category_counts[FileCategory.UNKNOWN],
55
+ )
56
+
57
+
58
+ def generate_review_focus(result_files: list[ChangedFile]) -> list[ReviewFocusItem]:
59
+ """Generate suggested review focus areas from file categories."""
60
+
61
+ categories = {item.category for item in result_files}
62
+ focus: list[ReviewFocusItem] = []
63
+
64
+ if FileCategory.SOURCE in categories:
65
+ focus.append(
66
+ ReviewFocusItem(
67
+ title="Validate behavior changes",
68
+ reason="Source files changed. Review correctness, edge cases, and backward compatibility.",
69
+ )
70
+ )
71
+
72
+ if FileCategory.TEST not in categories and FileCategory.SOURCE in categories:
73
+ focus.append(
74
+ ReviewFocusItem(
75
+ title="Check test coverage",
76
+ reason="Source files changed without test updates. Confirm whether existing tests are sufficient.",
77
+ )
78
+ )
79
+
80
+ if FileCategory.DEPENDENCY in categories:
81
+ focus.append(
82
+ ReviewFocusItem(
83
+ title="Review dependency impact",
84
+ reason="Dependency files changed. Check version compatibility, security, and lockfile consistency.",
85
+ )
86
+ )
87
+
88
+ if FileCategory.CI in categories:
89
+ focus.append(
90
+ ReviewFocusItem(
91
+ title="Review CI behavior",
92
+ reason="CI configuration changed. Check triggers, permissions, secrets, and required checks.",
93
+ )
94
+ )
95
+
96
+ if FileCategory.INFRA in categories:
97
+ focus.append(
98
+ ReviewFocusItem(
99
+ title="Review deployment impact",
100
+ reason="Infrastructure files changed. Check deployment, runtime, and environment behavior.",
101
+ )
102
+ )
103
+
104
+ if FileCategory.DOCS in categories:
105
+ focus.append(
106
+ ReviewFocusItem(
107
+ title="Verify documentation accuracy",
108
+ reason="Documentation changed. Confirm examples and usage notes match the implementation.",
109
+ )
110
+ )
111
+
112
+ if not focus:
113
+ focus.append(
114
+ ReviewFocusItem(
115
+ title="Review changed files",
116
+ reason="No specific category-based focus was detected. Review the changed files manually.",
117
+ )
118
+ )
119
+
120
+ return focus
121
+
122
+
123
+ def analyze_reviewpack_input(
124
+ reviewpack_input: ReviewpackInput,
125
+ config: ReviewpackConfig | None = None,
126
+ ) -> ReviewpackResult:
127
+ """Analyze Reviewpack input and return a structured result."""
128
+
129
+ active_config = config or ReviewpackConfig()
130
+ categorized_files = categorize_changed_files(reviewpack_input.changed_files, active_config)
131
+ stats = calculate_stats(categorized_files)
132
+ risk_signals = generate_risk_signals(categorized_files, active_config)
133
+ review_focus = generate_review_focus(categorized_files)
134
+
135
+ return ReviewpackResult(
136
+ pr=reviewpack_input.pr,
137
+ changed_files=categorized_files,
138
+ stats=stats,
139
+ risk_signals=risk_signals,
140
+ review_focus=review_focus,
141
+ metadata={
142
+ "reviewpack_version": "0.1.0",
143
+ "mode": "fixture",
144
+ "network_used": False,
145
+ "ai_used": False,
146
+ },
147
+ )
reviewpack/cli.py ADDED
@@ -0,0 +1,228 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from reviewpack.ai_preview import write_ai_input_preview
9
+ from reviewpack.analyzer import analyze_reviewpack_input
10
+ from reviewpack.config import load_config
11
+ from reviewpack.git import collect_changed_files_from_git
12
+ from reviewpack.github_client import GitHubAPIError, collect_reviewpack_input_from_github_url
13
+ from reviewpack.models import PullRequestInfo, ReviewpackInput
14
+ from reviewpack.renderers import write_reviewpack_outputs
15
+
16
+ app = typer.Typer(
17
+ name="reviewpack",
18
+ help="Privacy-first context packs for AI-assisted pull request review.",
19
+ )
20
+
21
+ console = Console()
22
+
23
+
24
+ @app.command("from-fixture")
25
+ def from_fixture(
26
+ fixture_path: Path = typer.Argument(
27
+ ...,
28
+ help="Path to a Reviewpack fixture JSON file.",
29
+ ),
30
+ output: Path = typer.Option(
31
+ Path(".reviewpack"),
32
+ "--output",
33
+ "-o",
34
+ help="Directory where Reviewpack output files will be written.",
35
+ ),
36
+ config: Path | None = typer.Option(
37
+ None,
38
+ "--config",
39
+ "-c",
40
+ help="Optional path to a .reviewpack.yml config file.",
41
+ ),
42
+ preview_ai_input: bool = typer.Option(
43
+ False,
44
+ "--preview-ai-input",
45
+ help="Generate a local AI input preview file without calling an AI provider.",
46
+ ),
47
+ ) -> None:
48
+ """Generate a review context pack from a local fixture JSON file."""
49
+
50
+ if not fixture_path.exists():
51
+ console.print(f"[red]Fixture file not found:[/red] {fixture_path}")
52
+ raise typer.Exit(code=1)
53
+
54
+ raw_json = fixture_path.read_text(encoding="utf-8")
55
+ reviewpack_input = ReviewpackInput.model_validate_json(raw_json)
56
+ reviewpack_config = load_config(config)
57
+
58
+ result = analyze_reviewpack_input(reviewpack_input, reviewpack_config)
59
+ write_reviewpack_outputs(result, output)
60
+
61
+ if preview_ai_input:
62
+ write_ai_input_preview(result, output)
63
+
64
+ print_success(output, preview_ai_input=preview_ai_input)
65
+
66
+
67
+ @app.command("local")
68
+ def local(
69
+ base: str = typer.Option(
70
+ "main",
71
+ "--base",
72
+ "-b",
73
+ help="Base git ref used for local diff.",
74
+ ),
75
+ head: str = typer.Option(
76
+ "HEAD",
77
+ "--head",
78
+ help="Head git ref used for local diff.",
79
+ ),
80
+ repo: Path = typer.Option(
81
+ Path("."),
82
+ "--repo",
83
+ help="Path to the local git repository.",
84
+ ),
85
+ output: Path = typer.Option(
86
+ Path(".reviewpack"),
87
+ "--output",
88
+ "-o",
89
+ help="Directory where Reviewpack output files will be written.",
90
+ ),
91
+ config: Path | None = typer.Option(
92
+ None,
93
+ "--config",
94
+ "-c",
95
+ help="Optional path to a .reviewpack.yml config file.",
96
+ ),
97
+ title: str = typer.Option(
98
+ "Local git diff",
99
+ "--title",
100
+ help="Title used in the generated review pack.",
101
+ ),
102
+ author: str = typer.Option(
103
+ "local",
104
+ "--author",
105
+ help="Author label used in the generated review pack.",
106
+ ),
107
+ preview_ai_input: bool = typer.Option(
108
+ False,
109
+ "--preview-ai-input",
110
+ help="Generate a local AI input preview file without calling an AI provider.",
111
+ ),
112
+ ) -> None:
113
+ """Generate a review context pack from a local git diff."""
114
+
115
+ reviewpack_config = load_config(config)
116
+
117
+ try:
118
+ changed_files = collect_changed_files_from_git(
119
+ base=base,
120
+ head=head,
121
+ repo_path=repo,
122
+ )
123
+ except RuntimeError as error:
124
+ console.print(f"[red]Failed to collect local git diff:[/red] {error}")
125
+ raise typer.Exit(code=1) from error
126
+
127
+ reviewpack_input = ReviewpackInput(
128
+ pr=PullRequestInfo(
129
+ title=title,
130
+ author=author,
131
+ ),
132
+ changed_files=changed_files,
133
+ )
134
+
135
+ result = analyze_reviewpack_input(reviewpack_input, reviewpack_config)
136
+ result.metadata["mode"] = "local_git"
137
+ result.metadata["network_used"] = False
138
+ result.metadata["ai_used"] = False
139
+
140
+ write_reviewpack_outputs(result, output)
141
+
142
+ if preview_ai_input:
143
+ write_ai_input_preview(result, output)
144
+
145
+ print_success(output, preview_ai_input=preview_ai_input)
146
+
147
+
148
+ @app.command("github")
149
+ def github(
150
+ pr_url: str = typer.Argument(
151
+ ...,
152
+ help="GitHub pull request URL.",
153
+ ),
154
+ output: Path = typer.Option(
155
+ Path(".reviewpack"),
156
+ "--output",
157
+ "-o",
158
+ help="Directory where Reviewpack output files will be written.",
159
+ ),
160
+ config: Path | None = typer.Option(
161
+ None,
162
+ "--config",
163
+ "-c",
164
+ help="Optional path to a .reviewpack.yml config file.",
165
+ ),
166
+ token: str | None = typer.Option(
167
+ None,
168
+ "--token",
169
+ help="Optional GitHub token. Prefer REVIEWPACK_GITHUB_TOKEN for local use.",
170
+ ),
171
+ preview_ai_input: bool = typer.Option(
172
+ False,
173
+ "--preview-ai-input",
174
+ help="Generate a local AI input preview file without calling an AI provider.",
175
+ ),
176
+ ) -> None:
177
+ """Generate a review context pack from GitHub PR metadata."""
178
+
179
+ reviewpack_config = load_config(config)
180
+
181
+ try:
182
+ reviewpack_input = collect_reviewpack_input_from_github_url(pr_url, token=token)
183
+ except (ValueError, GitHubAPIError) as error:
184
+ console.print(f"[red]Failed to collect GitHub pull request data:[/red] {error}")
185
+ raise typer.Exit(code=1) from error
186
+
187
+ result = analyze_reviewpack_input(reviewpack_input, reviewpack_config)
188
+ result.metadata["mode"] = "github"
189
+ result.metadata["network_used"] = True
190
+ result.metadata["ai_used"] = False
191
+
192
+ write_reviewpack_outputs(result, output)
193
+
194
+ if preview_ai_input:
195
+ write_ai_input_preview(result, output)
196
+
197
+ print_success(output, preview_ai_input=preview_ai_input)
198
+
199
+
200
+ @app.command("version")
201
+ def version() -> None:
202
+ """Show Reviewpack version."""
203
+
204
+ from reviewpack import __version__
205
+
206
+ console.print(__version__)
207
+
208
+
209
+ def print_success(output: Path, preview_ai_input: bool = False) -> None:
210
+ """Print generated output paths."""
211
+
212
+ console.print("[green]Reviewpack generated successfully.[/green]")
213
+ console.print(f"Output directory: {output}")
214
+ console.print("")
215
+ console.print("Generated files:")
216
+ console.print(f"- {output / 'pr-summary.md'}")
217
+ console.print(f"- {output / 'risk-checklist.md'}")
218
+ console.print(f"- {output / 'ai-review-prompt.md'}")
219
+ console.print(f"- {output / 'release-note-hints.md'}")
220
+ console.print(f"- {output / 'reviewer-checklist.md'}")
221
+ console.print(f"- {output / 'reviewpack.json'}")
222
+
223
+ if preview_ai_input:
224
+ console.print(f"- {output / 'ai-input-preview.md'}")
225
+
226
+
227
+ if __name__ == "__main__":
228
+ app()
reviewpack/config.py ADDED
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import yaml
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class PrivacyConfig(BaseModel):
10
+ """Privacy controls for Reviewpack.
11
+
12
+ Reviewpack is local-first and privacy-first by default.
13
+
14
+ These defaults intentionally avoid sending branch names, commit messages,
15
+ raw diffs, or local environment information to any external service.
16
+ """
17
+
18
+ include_branch_name: bool = False
19
+ include_commit_messages: bool = False
20
+ include_diff_snippets: bool = False
21
+ include_file_paths: bool = True
22
+ redact_secrets: bool = True
23
+
24
+
25
+ class AIConfig(BaseModel):
26
+ """Optional AI configuration.
27
+
28
+ AI is disabled by default. v0.1.0 does not call AI providers.
29
+ These fields reserve a stable configuration shape for future versions.
30
+ """
31
+
32
+ enabled: bool = False
33
+ provider: str | None = None
34
+ model: str | None = None
35
+ max_input_chars: int = 12000
36
+
37
+
38
+ class LargePRConfig(BaseModel):
39
+ """Thresholds for large pull request detection."""
40
+
41
+ changed_files: int = 20
42
+ changed_lines: int = 800
43
+
44
+
45
+ class ReviewpackConfig(BaseModel):
46
+ """Project-level Reviewpack configuration."""
47
+
48
+ risk_paths_high: list[str] = Field(
49
+ default_factory=lambda: [
50
+ "src/auth/**",
51
+ "src/security/**",
52
+ "src/payment/**",
53
+ ]
54
+ )
55
+ test_paths: list[str] = Field(
56
+ default_factory=lambda: [
57
+ "tests/**",
58
+ "__tests__/**",
59
+ "test/**",
60
+ ]
61
+ )
62
+ docs_paths: list[str] = Field(
63
+ default_factory=lambda: [
64
+ "README.md",
65
+ "docs/**",
66
+ ]
67
+ )
68
+ large_pr: LargePRConfig = Field(default_factory=LargePRConfig)
69
+ privacy: PrivacyConfig = Field(default_factory=PrivacyConfig)
70
+ ai: AIConfig = Field(default_factory=AIConfig)
71
+
72
+
73
+ def load_config(path: str | Path | None = None) -> ReviewpackConfig:
74
+ """Load Reviewpack configuration.
75
+
76
+ If no path is provided, the default configuration is returned.
77
+ If the file does not exist, the default configuration is returned.
78
+
79
+ The first public version keeps configuration loading intentionally simple.
80
+ """
81
+
82
+ if path is None:
83
+ return ReviewpackConfig()
84
+
85
+ config_path = Path(path)
86
+
87
+ if not config_path.exists():
88
+ return ReviewpackConfig()
89
+
90
+ data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
91
+ return ReviewpackConfig.model_validate(data)
reviewpack/git.py ADDED
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from reviewpack.models import ChangedFile
7
+
8
+
9
+ def parse_numstat_value(value: str) -> int:
10
+ """Parse a git numstat value.
11
+
12
+ Git reports binary files as "-". Reviewpack treats those as zero-line changes
13
+ for deterministic summary purposes.
14
+ """
15
+
16
+ if value == "-":
17
+ return 0
18
+
19
+ return int(value)
20
+
21
+
22
+ def parse_numstat_line(line: str) -> ChangedFile | None:
23
+ """Parse one line of git diff numstat output.
24
+
25
+ Expected format:
26
+ additions, deletions, path separated by tab characters.
27
+ """
28
+
29
+ parts = line.rstrip("\n").split("\t")
30
+
31
+ if len(parts) < 3:
32
+ return None
33
+
34
+ additions_raw = parts[0]
35
+ deletions_raw = parts[1]
36
+ path = parts[-1]
37
+
38
+ return ChangedFile(
39
+ path=path,
40
+ additions=parse_numstat_value(additions_raw),
41
+ deletions=parse_numstat_value(deletions_raw),
42
+ )
43
+
44
+
45
+ def parse_numstat(text: str) -> list[ChangedFile]:
46
+ """Parse git diff numstat output into changed files."""
47
+
48
+ files: list[ChangedFile] = []
49
+
50
+ for line in text.splitlines():
51
+ if not line.strip():
52
+ continue
53
+
54
+ changed_file = parse_numstat_line(line)
55
+
56
+ if changed_file is not None:
57
+ files.append(changed_file)
58
+
59
+ return files
60
+
61
+
62
+ def collect_changed_files_from_git(
63
+ base: str = "main",
64
+ head: str = "HEAD",
65
+ repo_path: str | Path = ".",
66
+ ) -> list[ChangedFile]:
67
+ """Collect changed files from a local git repository.
68
+
69
+ This function only uses local git metadata. It does not use network access,
70
+ GitHub APIs, AI providers, environment variables, or external services.
71
+ """
72
+
73
+ repository = Path(repo_path)
74
+
75
+ command = [
76
+ "git",
77
+ "diff",
78
+ "--numstat",
79
+ f"{base}...{head}",
80
+ ]
81
+
82
+ completed = subprocess.run(
83
+ command,
84
+ cwd=repository,
85
+ capture_output=True,
86
+ text=True,
87
+ check=False,
88
+ )
89
+
90
+ if completed.returncode != 0:
91
+ message = completed.stderr.strip() or "git diff failed"
92
+ raise RuntimeError(message)
93
+
94
+ return parse_numstat(completed.stdout)