diffbutler 0.6.1__tar.gz → 0.7.0__tar.gz
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.
- {diffbutler-0.6.1 → diffbutler-0.7.0}/PKG-INFO +3 -2
- {diffbutler-0.6.1 → diffbutler-0.7.0}/README.md +1 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/pyproject.toml +4 -3
- {diffbutler-0.6.1 → diffbutler-0.7.0}/pyproject.toml.orig +4 -3
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/config/default.toml +0 -3
- diffbutler-0.7.0/src/diffbutler/config/editor.py +535 -0
- diffbutler-0.7.0/src/diffbutler/config/file.py +70 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/config/settings.py +100 -17
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/config/setup.py +16 -27
- diffbutler-0.7.0/src/diffbutler/config/transport.py +58 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/context.py +3 -1
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/dashboard.py +13 -5
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/managed.py +1 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/native.py +29 -5
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/polling.py +22 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/workspaces.py +54 -39
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/domain/polling.py +7 -2
- diffbutler-0.7.0/src/diffbutler/domain/redaction.py +90 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/domain/sync.py +42 -3
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/forges/base.py +25 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/forges/github/adapter.py +19 -12
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/reviewers/native.py +22 -4
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/reviewers/presets.toml +4 -2
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/sources/git.py +62 -32
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/database.py +66 -10
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/lifecycle.py +2 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/queue.py +69 -12
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/review.py +4 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/cli/__init__.py +15 -6
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/tui/_foreground_exec.py +2 -1
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/tui/dashboard.py +55 -4
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/tui/foreground.py +6 -1
- diffbutler-0.7.0/src/diffbutler/ui/tui/settings.py +337 -0
- diffbutler-0.6.1/src/diffbutler/config/file.py +0 -30
- diffbutler-0.6.1/src/diffbutler/config/transport.py +0 -65
- diffbutler-0.6.1/src/diffbutler/domain/redaction.py +0 -61
- {diffbutler-0.6.1 → diffbutler-0.7.0}/LICENSE.txt +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/__init__.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/__main__.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/config/__init__.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/config/diagnostics.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/config/instance.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/__init__.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/application.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/doctor.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/lifecycle.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/pr_lookup.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/process.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/repositories.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/reset.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/resources.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/review.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/setup.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/snapshot.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/sync.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/worker.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/domain/__init__.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/domain/dashboard.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/domain/diff.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/domain/forge.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/domain/lifecycle.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/domain/process.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/domain/resources.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/domain/review.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/forges/__init__.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/forges/diagnostics.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/forges/github/__init__.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/forges/github/auth.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/forges/github/http.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/forges/github/mapping.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/forges/github/repositories.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/forges/github/setup.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/forges/registry.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/py.typed +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/reviewers/__init__.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/reviewers/base.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/reviewers/extraction.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/reviewers/parsing.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/reviewers/process.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/reviewers/prompt.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/reviewers/redaction.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/reviewers/resources.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/sources/__init__.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/sources/diagnostics.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/sources/fetch.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/sources/remotes.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/__init__.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/base.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/dashboard.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/__init__.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/env.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/versions/0001_initial.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/versions/0002_monitoring.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/versions/0003_reviews.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/versions/0004_claims.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/versions/0005_results.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/versions/0006_anchors.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/versions/0007_lifecycle.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/versions/0008_processes.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/versions/0009_dashboard.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/versions/0010_run_reasons.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/versions/0011_explicit_reviews.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/versions/__init__.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/models.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/process.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/__init__.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/cli/editor.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/tui/__init__.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/tui/_fake_agent.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/tui/app.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/tui/browse.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/tui/browser.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/tui/handoff_probe.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/tui/review_prompt.py +0 -0
- {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/tui/terminal_theme.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: diffbutler
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: A terminal dashboard for human-directed AI code reviews.
|
|
5
5
|
Keywords: code-review,github,terminal,ai
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
License-File: LICENSE.txt
|
|
8
|
-
Classifier: Development Status ::
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
9
|
Classifier: Environment :: Console
|
|
10
10
|
Classifier: Intended Audience :: Developers
|
|
11
11
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
@@ -149,6 +149,7 @@ and read-only review instructions; source sent to the agent may go to its model
|
|
|
149
149
|
- `k`: Stop the selected PR's review and reset its local tracking and owned resources.
|
|
150
150
|
It disappears until a forge refresh rediscovers it; review history is retained.
|
|
151
151
|
- `h`: View completed review history.
|
|
152
|
+
- `,`: Edit settings; saved changes apply without restarting.
|
|
152
153
|
- `?`: Show all keyboard shortcuts.
|
|
153
154
|
- `q`: Quit.
|
|
154
155
|
|
|
@@ -121,6 +121,7 @@ and read-only review instructions; source sent to the agent may go to its model
|
|
|
121
121
|
- `k`: Stop the selected PR's review and reset its local tracking and owned resources.
|
|
122
122
|
It disappears until a forge refresh rediscovers it; review history is retained.
|
|
123
123
|
- `h`: View completed review history.
|
|
124
|
+
- `,`: Edit settings; saved changes apply without restarting.
|
|
124
125
|
- `?`: Show all keyboard shortcuts.
|
|
125
126
|
- `q`: Quit.
|
|
126
127
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "diffbutler"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.7.0"
|
|
4
4
|
description = "A terminal dashboard for human-directed AI code reviews."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
@@ -13,7 +13,7 @@ keywords = [
|
|
|
13
13
|
"ai",
|
|
14
14
|
]
|
|
15
15
|
classifiers = [
|
|
16
|
-
"Development Status ::
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
17
|
"Environment :: Console",
|
|
18
18
|
"Intended Audience :: Developers",
|
|
19
19
|
"Programming Language :: Python :: 3 :: Only",
|
|
@@ -53,11 +53,12 @@ dev = [
|
|
|
53
53
|
"pytest-cov>=7.1.0",
|
|
54
54
|
"pytest-xdist>=3.8.0",
|
|
55
55
|
"ruff>=0.16.5",
|
|
56
|
+
"zizmor>=1.32.0",
|
|
56
57
|
]
|
|
57
58
|
|
|
58
59
|
[tool.uv]
|
|
59
60
|
required-version = ">=0.12.13"
|
|
60
|
-
exclude-newer = "
|
|
61
|
+
exclude-newer = "8 days"
|
|
61
62
|
|
|
62
63
|
[tool.ruff]
|
|
63
64
|
target-version = "py314"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "diffbutler"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.7.0"
|
|
4
4
|
description = "A terminal dashboard for human-directed AI code reviews."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
@@ -8,7 +8,7 @@ license = "MIT"
|
|
|
8
8
|
license-files = ["LICENSE.txt"]
|
|
9
9
|
keywords = ["code-review", "github", "terminal", "ai"]
|
|
10
10
|
classifiers = [
|
|
11
|
-
"Development Status ::
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
12
|
"Environment :: Console",
|
|
13
13
|
"Intended Audience :: Developers",
|
|
14
14
|
"Programming Language :: Python :: 3 :: Only",
|
|
@@ -48,12 +48,13 @@ dev = [
|
|
|
48
48
|
"pytest-cov>=7.1.0",
|
|
49
49
|
"pytest-xdist>=3.8.0",
|
|
50
50
|
"ruff>=0.16.5",
|
|
51
|
+
"zizmor>=1.32.0",
|
|
51
52
|
]
|
|
52
53
|
|
|
53
54
|
[tool.uv]
|
|
54
55
|
required-version = ">=0.12.13"
|
|
55
56
|
# Give new dependency releases time to settle before routine upgrades.
|
|
56
|
-
exclude-newer = "
|
|
57
|
+
exclude-newer = "8 days"
|
|
57
58
|
|
|
58
59
|
[tool.ruff]
|
|
59
60
|
target-version = "py314"
|
|
@@ -27,9 +27,7 @@ auto_migrate = true
|
|
|
27
27
|
[review]
|
|
28
28
|
findings_threshold = "minor" # blocking | important | minor | nit | question
|
|
29
29
|
min_confidence = 0.5
|
|
30
|
-
exclude_globs = ["**/*.lock", "**/vendor/**", "**/*.min.js", "**/generated/**"]
|
|
31
30
|
mode = "source_only"
|
|
32
|
-
checkout = "worktree" # Native reviews currently use pinned source snapshots.
|
|
33
31
|
# default_reviewer = "codex"
|
|
34
32
|
# prompt = "~/review-instructions.md"
|
|
35
33
|
|
|
@@ -60,7 +58,6 @@ open_in_browser = ["o", "ctrl+o"]
|
|
|
60
58
|
# git_transport = "auto" # auto | https | ssh; remembers the last successful fetch.
|
|
61
59
|
# default_reviewer = "codex"
|
|
62
60
|
# prompt = "~/review-instructions.md"
|
|
63
|
-
# checkout = "mediated"
|
|
64
61
|
|
|
65
62
|
# Remove the top-level forges = [] before adding forge accounts:
|
|
66
63
|
# [[forges]]
|
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""Edit configuration values in place, preserving comments and concurrent file changes.
|
|
2
|
+
|
|
3
|
+
The editor works on what the file says, not on effective settings: an unset value
|
|
4
|
+
shows its built-in default and is only written once changed. Saving re-reads the
|
|
5
|
+
file under the config lock and applies just the edited values, so background
|
|
6
|
+
updates such as a remembered Git transport survive. A value edited both here and
|
|
7
|
+
in the file since opening is reported as a conflict instead of being overwritten.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
import os
|
|
12
|
+
import shlex
|
|
13
|
+
import tomllib
|
|
14
|
+
import types
|
|
15
|
+
from collections.abc import Mapping, MutableMapping, MutableSequence
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Literal, Union, cast, get_args, get_origin
|
|
19
|
+
|
|
20
|
+
import tomlkit
|
|
21
|
+
from pydantic import BaseModel
|
|
22
|
+
from tomlkit.exceptions import TOMLKitError
|
|
23
|
+
from tomlkit.items import AoT, InlineTable
|
|
24
|
+
|
|
25
|
+
from diffbutler.config.file import config_lock, publish_config
|
|
26
|
+
from diffbutler.config.settings import (
|
|
27
|
+
UI,
|
|
28
|
+
ConfigError,
|
|
29
|
+
Database,
|
|
30
|
+
Forge,
|
|
31
|
+
General,
|
|
32
|
+
Repository,
|
|
33
|
+
Review,
|
|
34
|
+
Reviewer,
|
|
35
|
+
config_path,
|
|
36
|
+
validate_document,
|
|
37
|
+
)
|
|
38
|
+
from diffbutler.domain.dashboard import COLUMN_IDS
|
|
39
|
+
|
|
40
|
+
type Value = bool | int | float | str | list[str] | None
|
|
41
|
+
FieldKind = Literal["bool", "choice", "int", "float", "text", "words"]
|
|
42
|
+
SectionKind = Literal["table", "list", "map"]
|
|
43
|
+
|
|
44
|
+
HELP: dict[str, str] = {
|
|
45
|
+
"general.workspace_root": "Review home for session workspaces and conversations. Existing "
|
|
46
|
+
"data is not moved; DiffButler uses the new location from its next operation.",
|
|
47
|
+
"general.repository_root": "Shared bare Git repositories. Existing data is not moved; "
|
|
48
|
+
"DiffButler uses the new location from its next operation.",
|
|
49
|
+
"general.repository_retention_days": "Evict unused shared repositories after this many days.",
|
|
50
|
+
"general.snapshot_root": "Optional private directory for reconstructible source snapshots, "
|
|
51
|
+
"separate from the other managed roots.",
|
|
52
|
+
"general.poll_interval_s": "Seconds between forge refreshes. Empty uses 60 with "
|
|
53
|
+
"authenticated accounts and 600 with any public account; low quota slows polling further.",
|
|
54
|
+
"general.max_concurrent_reviews": "Background AI reviews allowed to run at once.",
|
|
55
|
+
"db.url": "SQLite database URL. Existing data is not moved; DiffButler uses the new "
|
|
56
|
+
"database from its next operation.",
|
|
57
|
+
"db.auto_migrate": "Back up and migrate an older database schema automatically.",
|
|
58
|
+
"review.default_reviewer": "Reviewer ID used when none is chosen; must name a reviewer.",
|
|
59
|
+
"review.findings_threshold": "Lowest severity shown as a finding.",
|
|
60
|
+
"review.min_confidence": "Lowest reviewer confidence (0 to 1) shown as a finding.",
|
|
61
|
+
"review.mode": "Background reviews only read source.",
|
|
62
|
+
"review.prompt": "Optional file with extra review instructions.",
|
|
63
|
+
"forges.kind": "Forge type; only GitHub is implemented.",
|
|
64
|
+
"forges.base_url": "API URL, for example https://api.github.com.",
|
|
65
|
+
"forges.token_env": "Name of the environment variable holding the token, never the token. "
|
|
66
|
+
"Empty uses public access, which requires a login.",
|
|
67
|
+
"forges.include_team_requests": "Include review requests made to your teams.",
|
|
68
|
+
"forges.login": "Your username on this forge.",
|
|
69
|
+
"forges.repositories": "Monitored repositories as owner/repo or owner/*, space-separated. "
|
|
70
|
+
"Empty searches review requests in all accessible repositories.",
|
|
71
|
+
"reviewers.id": "Unique name used to select this reviewer.",
|
|
72
|
+
"reviewers.kind": "Reviewer type.",
|
|
73
|
+
"reviewers.command": "Executable and arguments, space-separated with shell-style quoting; "
|
|
74
|
+
"no shell runs.",
|
|
75
|
+
"reviewers.preset": "Native Codex or Claude Code integration.",
|
|
76
|
+
"reviewers.timeout_s": "Seconds before a background review is stopped.",
|
|
77
|
+
"reviewers.parse": "Output format of custom reviewers.",
|
|
78
|
+
"reviewers.json_path": "Field holding the review in JSON output.",
|
|
79
|
+
"reviewers.stdin": "Send the prompt on standard input.",
|
|
80
|
+
"ui.include_own_prs": "Show PRs authored by your account in the queue.",
|
|
81
|
+
"ui.include_assigned_prs": "Show PRs assigned to your account in the queue.",
|
|
82
|
+
"ui.browser_command": "Browser command and arguments; the PR URL is appended. "
|
|
83
|
+
"Empty uses the system browser.",
|
|
84
|
+
"ui.columns": "Queue columns, space-separated: " + " ".join(COLUMN_IDS) + ".",
|
|
85
|
+
"ui.sort_by": "Default sort column; a sort chosen in the dashboard takes precedence.",
|
|
86
|
+
"ui.sort_desc": "Sort in descending order by default.",
|
|
87
|
+
"ui.preview_pane": "Show details of the selected PR below the queue.",
|
|
88
|
+
"ui.default_filter": "View shown when the dashboard starts.",
|
|
89
|
+
"ui.attention.blocking": "Attention score added for blocking findings.",
|
|
90
|
+
"ui.attention.important": "Attention score added for important findings.",
|
|
91
|
+
"ui.attention.age_pressure_per_day": "Attention score added per day of PR age.",
|
|
92
|
+
"ui.attention.size_penalty_per_100_lines": "Attention score removed per 100 changed lines.",
|
|
93
|
+
"repos.enabled": "Repository AI opt-in metadata; it never starts a review.",
|
|
94
|
+
"repos.path": "Local checkout used as a source for this repository.",
|
|
95
|
+
"repos.default_reviewer": "Reviewer ID for this repository; overrides the review default.",
|
|
96
|
+
"repos.prompt": "Review instructions file for this repository.",
|
|
97
|
+
"repos.git_transport": "Preferred Git transport; a successful fetch remembers it.",
|
|
98
|
+
"keys.keys": "Keys for this dashboard action, space-separated, for example: o ctrl+o.",
|
|
99
|
+
}
|
|
100
|
+
CHOICES: dict[str, tuple[str, ...]] = {"ui.sort_by": COLUMN_IDS}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class FieldSpec:
|
|
105
|
+
label: str # Dotted path within its table, e.g. "attention.blocking".
|
|
106
|
+
kind: FieldKind
|
|
107
|
+
optional: bool
|
|
108
|
+
required: bool
|
|
109
|
+
default: Value
|
|
110
|
+
choices: tuple[str, ...] = ()
|
|
111
|
+
help: str = ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class SectionSpec:
|
|
116
|
+
name: str
|
|
117
|
+
title: str
|
|
118
|
+
kind: SectionKind
|
|
119
|
+
fields: tuple[FieldSpec, ...]
|
|
120
|
+
entry_title: str = ""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class Entry:
|
|
125
|
+
"""One forge, reviewer, repository or key binding; origin locates it in the file."""
|
|
126
|
+
|
|
127
|
+
values: dict[str, Value]
|
|
128
|
+
origin: int | str | None = None
|
|
129
|
+
name: str = ""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class Draft:
|
|
134
|
+
tables: dict[str, dict[str, Value]] = field(default_factory=dict[str, dict[str, Value]])
|
|
135
|
+
entries: dict[str, list[Entry]] = field(default_factory=dict[str, list[Entry]])
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def plain(value: object) -> Value:
|
|
139
|
+
if isinstance(value, Path):
|
|
140
|
+
return str(value)
|
|
141
|
+
if isinstance(value, list):
|
|
142
|
+
return [str(item) for item in cast(list[object], value)]
|
|
143
|
+
return cast(Value, value)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def field_specs(section: str, model: type[BaseModel], prefix: str = "") -> list[FieldSpec]:
|
|
147
|
+
specs: list[FieldSpec] = []
|
|
148
|
+
for name, info in model.model_fields.items():
|
|
149
|
+
if info.exclude:
|
|
150
|
+
continue # Legacy settings that are accepted but ignored.
|
|
151
|
+
label = prefix + name
|
|
152
|
+
annotation: Any = info.annotation
|
|
153
|
+
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
|
154
|
+
specs.extend(field_specs(section, annotation, label + "."))
|
|
155
|
+
continue
|
|
156
|
+
optional = False
|
|
157
|
+
if get_origin(annotation) in {Union, types.UnionType}:
|
|
158
|
+
arguments = [item for item in get_args(annotation) if item is not type(None)]
|
|
159
|
+
optional = len(arguments) < len(get_args(annotation))
|
|
160
|
+
(annotation,) = arguments
|
|
161
|
+
choices: tuple[str, ...] = CHOICES.get(f"{section}.{label}", ())
|
|
162
|
+
if get_origin(annotation) is Literal:
|
|
163
|
+
kind: FieldKind = "choice"
|
|
164
|
+
choices = tuple(get_args(annotation))
|
|
165
|
+
elif choices:
|
|
166
|
+
kind = "choice"
|
|
167
|
+
elif annotation is bool:
|
|
168
|
+
kind = "bool"
|
|
169
|
+
elif annotation is int:
|
|
170
|
+
kind = "int"
|
|
171
|
+
elif annotation is float:
|
|
172
|
+
kind = "float"
|
|
173
|
+
elif annotation in {str, Path}:
|
|
174
|
+
kind = "text"
|
|
175
|
+
elif get_origin(annotation) is list and get_args(annotation) == (str,):
|
|
176
|
+
kind = "words"
|
|
177
|
+
else:
|
|
178
|
+
raise TypeError(f"No settings editor for {section}.{label}")
|
|
179
|
+
required = info.is_required()
|
|
180
|
+
specs.append(
|
|
181
|
+
FieldSpec(
|
|
182
|
+
label,
|
|
183
|
+
kind,
|
|
184
|
+
optional,
|
|
185
|
+
required,
|
|
186
|
+
None if required else plain(info.get_default(call_default_factory=True)),
|
|
187
|
+
choices,
|
|
188
|
+
HELP.get(f"{section}.{label}", ""),
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
return specs
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
SECTIONS: tuple[SectionSpec, ...] = (
|
|
195
|
+
SectionSpec("general", "General", "table", tuple(field_specs("general", General))),
|
|
196
|
+
SectionSpec("db", "Database", "table", tuple(field_specs("db", Database))),
|
|
197
|
+
SectionSpec("review", "Review", "table", tuple(field_specs("review", Review))),
|
|
198
|
+
SectionSpec(
|
|
199
|
+
"reviewers", "Reviewers", "list", tuple(field_specs("reviewers", Reviewer)), "Reviewer"
|
|
200
|
+
),
|
|
201
|
+
SectionSpec("forges", "Forges", "list", tuple(field_specs("forges", Forge)), "Forge"),
|
|
202
|
+
SectionSpec(
|
|
203
|
+
"repos",
|
|
204
|
+
"Repositories",
|
|
205
|
+
"map",
|
|
206
|
+
tuple(field_specs("repos", Repository)),
|
|
207
|
+
"Repository",
|
|
208
|
+
),
|
|
209
|
+
SectionSpec("ui", "Interface", "table", tuple(field_specs("ui", UI))),
|
|
210
|
+
SectionSpec(
|
|
211
|
+
"keys",
|
|
212
|
+
"Keys",
|
|
213
|
+
"map",
|
|
214
|
+
(FieldSpec("keys", "words", False, True, None, help=HELP["keys.keys"]),),
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
SECTION = {section.name: section for section in SECTIONS}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
EMPTY_LIST = "[]" # Distinguishes an empty list from an unset one in text input.
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def format_value(spec: FieldSpec, value: Value) -> str:
|
|
224
|
+
if value is None:
|
|
225
|
+
return ""
|
|
226
|
+
if spec.kind == "words":
|
|
227
|
+
if value == []:
|
|
228
|
+
return EMPTY_LIST
|
|
229
|
+
return shlex.join([value] if isinstance(value, str) else cast(list[str], value))
|
|
230
|
+
if isinstance(value, bool):
|
|
231
|
+
return "true" if value else "false"
|
|
232
|
+
return str(value)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def parse_value(spec: FieldSpec, text: str) -> Value:
|
|
236
|
+
"""Convert text input; empty means unset, so the built-in default applies."""
|
|
237
|
+
text = text.strip()
|
|
238
|
+
if not text:
|
|
239
|
+
return None
|
|
240
|
+
if spec.kind == "words" and text == EMPTY_LIST:
|
|
241
|
+
return []
|
|
242
|
+
try:
|
|
243
|
+
match spec.kind:
|
|
244
|
+
case "int":
|
|
245
|
+
return int(text)
|
|
246
|
+
case "float":
|
|
247
|
+
return float(text)
|
|
248
|
+
case "words":
|
|
249
|
+
return shlex.split(text)
|
|
250
|
+
case _:
|
|
251
|
+
return text
|
|
252
|
+
except ValueError:
|
|
253
|
+
expected = {"int": "a whole number", "float": "a number", "words": "matching quotes"}
|
|
254
|
+
raise ConfigError(f"{spec.label}: enter {expected[spec.kind]}") from None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def overridden(label: str, environ: Mapping[str, str] | None = None) -> bool:
|
|
258
|
+
"""Whether a DIFFBUTLER_* variable overrides a dotted setting or one of its parents."""
|
|
259
|
+
names = {
|
|
260
|
+
name.upper() for name in (os.environ if environ is None else environ) if name.isascii()
|
|
261
|
+
}
|
|
262
|
+
parts = label.upper().split(".")
|
|
263
|
+
return any(
|
|
264
|
+
"DIFFBUTLER_" + "__".join(parts[:length]) in names for length in range(1, len(parts) + 1)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def lookup(table: object, label: str) -> Value:
|
|
269
|
+
for part in label.split("."):
|
|
270
|
+
if not isinstance(table, Mapping):
|
|
271
|
+
return None
|
|
272
|
+
table = cast(Mapping[str, object], table).get(part)
|
|
273
|
+
return plain(table)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def project(document: Mapping[str, Any]) -> Draft:
|
|
277
|
+
draft = Draft()
|
|
278
|
+
for section in SECTIONS:
|
|
279
|
+
content: object = document.get(section.name)
|
|
280
|
+
if section.kind == "table":
|
|
281
|
+
draft.tables[section.name] = {
|
|
282
|
+
spec.label: lookup(content, spec.label) for spec in section.fields
|
|
283
|
+
}
|
|
284
|
+
elif section.kind == "list":
|
|
285
|
+
draft.entries[section.name] = [
|
|
286
|
+
Entry({spec.label: lookup(entry, spec.label) for spec in section.fields}, index)
|
|
287
|
+
for index, entry in enumerate(cast(list[object], content or []))
|
|
288
|
+
]
|
|
289
|
+
else:
|
|
290
|
+
draft.entries[section.name] = [
|
|
291
|
+
Entry(
|
|
292
|
+
{"keys": plain(entry)}
|
|
293
|
+
if section.name == "keys"
|
|
294
|
+
else {spec.label: lookup(entry, spec.label) for spec in section.fields},
|
|
295
|
+
name,
|
|
296
|
+
name,
|
|
297
|
+
)
|
|
298
|
+
for name, entry in cast(dict[str, object], content or {}).items()
|
|
299
|
+
]
|
|
300
|
+
return draft
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def assign(table: MutableMapping[str, Any], label: str, value: Value) -> None:
|
|
304
|
+
*parents, key = label.split(".")
|
|
305
|
+
for part in parents:
|
|
306
|
+
if part not in table:
|
|
307
|
+
if value is None:
|
|
308
|
+
return
|
|
309
|
+
table[part] = (
|
|
310
|
+
tomlkit.inline_table() if isinstance(table, InlineTable) else tomlkit.table()
|
|
311
|
+
)
|
|
312
|
+
table = cast(MutableMapping[str, Any], table[part])
|
|
313
|
+
if value is None:
|
|
314
|
+
table.pop(key, None)
|
|
315
|
+
else:
|
|
316
|
+
table[key] = value
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def new_table(values: Mapping[str, Value], *, inline: bool = False) -> Any:
|
|
320
|
+
table = tomlkit.inline_table() if inline else tomlkit.table()
|
|
321
|
+
for label, value in values.items():
|
|
322
|
+
assign(table, label, value)
|
|
323
|
+
if not inline:
|
|
324
|
+
table.add(tomlkit.nl())
|
|
325
|
+
return table
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def conflict(where: str) -> ConfigError:
|
|
329
|
+
return ConfigError(
|
|
330
|
+
f"{where} changed in the config file while Settings was open; "
|
|
331
|
+
"close Settings and open it again to edit the new values"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def entry_label(section: SectionSpec, entry: Entry) -> str:
|
|
336
|
+
if isinstance(entry.origin, int):
|
|
337
|
+
return f"{section.entry_title} {entry.origin + 1}"
|
|
338
|
+
return f"{section.entry_title or 'Key'} {entry.name or entry.origin}"
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def apply(document: MutableMapping[str, Any], base: Draft, current: Draft, draft: Draft) -> bool:
|
|
342
|
+
"""Apply the values edited between base and draft onto the re-read document."""
|
|
343
|
+
changed = False
|
|
344
|
+
for section in SECTIONS:
|
|
345
|
+
name = section.name
|
|
346
|
+
if section.kind == "table":
|
|
347
|
+
for label, value in draft.tables[name].items():
|
|
348
|
+
old = base.tables[name][label]
|
|
349
|
+
if value == old:
|
|
350
|
+
continue
|
|
351
|
+
if current.tables[name][label] != old:
|
|
352
|
+
raise conflict(f"{name}.{label}")
|
|
353
|
+
if name not in document:
|
|
354
|
+
if value is None:
|
|
355
|
+
continue
|
|
356
|
+
document[name] = tomlkit.table()
|
|
357
|
+
assign(cast(MutableMapping[str, Any], document[name]), label, value)
|
|
358
|
+
changed = True
|
|
359
|
+
elif section.kind == "list":
|
|
360
|
+
changed |= apply_list(document, section, base.entries[name], current, draft)
|
|
361
|
+
else:
|
|
362
|
+
changed |= apply_map(document, section, base.entries[name], current, draft)
|
|
363
|
+
return changed
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def apply_list(
|
|
367
|
+
document: MutableMapping[str, Any],
|
|
368
|
+
section: SectionSpec,
|
|
369
|
+
base: list[Entry],
|
|
370
|
+
current: Draft,
|
|
371
|
+
draft: Draft,
|
|
372
|
+
) -> bool:
|
|
373
|
+
entries = draft.entries[section.name]
|
|
374
|
+
if entries == base:
|
|
375
|
+
return False
|
|
376
|
+
if [entry.values for entry in current.entries[section.name]] != [
|
|
377
|
+
entry.values for entry in base
|
|
378
|
+
]:
|
|
379
|
+
raise conflict(section.title)
|
|
380
|
+
kept = sorted(entry.origin for entry in entries if isinstance(entry.origin, int))
|
|
381
|
+
array = cast(MutableSequence[object] | None, document.get(section.name))
|
|
382
|
+
if array is None or (not array and not isinstance(array, AoT)):
|
|
383
|
+
# Replace the default empty inline array with tables at the end of the file.
|
|
384
|
+
document.pop(section.name, None)
|
|
385
|
+
document[section.name] = tomlkit.aot()
|
|
386
|
+
array = cast(MutableSequence[object], document[section.name])
|
|
387
|
+
for index in reversed(range(len(array))):
|
|
388
|
+
if index not in kept:
|
|
389
|
+
del array[index]
|
|
390
|
+
for entry in entries:
|
|
391
|
+
if isinstance(entry.origin, int):
|
|
392
|
+
table = cast(MutableMapping[str, Any], array[kept.index(entry.origin)])
|
|
393
|
+
for label, value in entry.values.items():
|
|
394
|
+
if value != base[entry.origin].values[label]:
|
|
395
|
+
assign(table, label, value)
|
|
396
|
+
else:
|
|
397
|
+
# Keep the file's style; a standard table inside an inline array is invalid.
|
|
398
|
+
array.append(new_table(entry.values, inline=not isinstance(array, AoT)))
|
|
399
|
+
if not array:
|
|
400
|
+
document.pop(section.name)
|
|
401
|
+
return True
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def apply_map(
|
|
405
|
+
document: MutableMapping[str, Any],
|
|
406
|
+
section: SectionSpec,
|
|
407
|
+
base: list[Entry],
|
|
408
|
+
current: Draft,
|
|
409
|
+
draft: Draft,
|
|
410
|
+
) -> bool:
|
|
411
|
+
entries = draft.entries[section.name]
|
|
412
|
+
if entries == base:
|
|
413
|
+
return False
|
|
414
|
+
names = [entry.name.strip() for entry in entries]
|
|
415
|
+
for entry, name in zip(entries, names, strict=True):
|
|
416
|
+
if not name:
|
|
417
|
+
raise ConfigError(f"{entry_label(section, entry)}: enter a name")
|
|
418
|
+
if len(set(names)) != len(names):
|
|
419
|
+
raise ConfigError(f"{section.title}: names must be unique")
|
|
420
|
+
originals = {cast(str, entry.origin): entry.values for entry in base}
|
|
421
|
+
present = {cast(str, entry.origin): entry.values for entry in current.entries[section.name]}
|
|
422
|
+
renamed = {
|
|
423
|
+
cast(str, entry.origin): name
|
|
424
|
+
for entry, name in zip(entries, names, strict=True)
|
|
425
|
+
if entry.origin is not None and name != entry.origin
|
|
426
|
+
}
|
|
427
|
+
edited = {
|
|
428
|
+
cast(str, entry.origin)
|
|
429
|
+
for entry in entries
|
|
430
|
+
if entry.origin is not None and entry.values != originals[cast(str, entry.origin)]
|
|
431
|
+
}
|
|
432
|
+
removed = set(originals) - {entry.origin for entry in entries}
|
|
433
|
+
title = section.entry_title or "Key"
|
|
434
|
+
# Removing or renaming replaces the whole entry; an edit touches only its changed values.
|
|
435
|
+
for name in removed | set(renamed):
|
|
436
|
+
if present.get(name) != originals[name]:
|
|
437
|
+
raise conflict(f"{title} {name}")
|
|
438
|
+
for entry in entries:
|
|
439
|
+
origin = cast(str, entry.origin)
|
|
440
|
+
if origin in edited and origin not in renamed:
|
|
441
|
+
for label, value in entry.values.items():
|
|
442
|
+
old = originals[origin][label]
|
|
443
|
+
if value != old and (origin not in present or present[origin][label] != old):
|
|
444
|
+
raise conflict(f"{title} {origin}")
|
|
445
|
+
vacated = removed | set(renamed)
|
|
446
|
+
for entry, name in zip(entries, names, strict=True):
|
|
447
|
+
if name != entry.origin and name in present and name not in vacated:
|
|
448
|
+
raise conflict(f"{title} {name}")
|
|
449
|
+
if section.name not in document:
|
|
450
|
+
document[section.name] = tomlkit.table(is_super_table=True)
|
|
451
|
+
table = cast(MutableMapping[str, Any], document[section.name])
|
|
452
|
+
moved = {origin: table.pop(origin) for origin in renamed}
|
|
453
|
+
for name in removed:
|
|
454
|
+
del table[name]
|
|
455
|
+
for entry, name in zip(entries, names, strict=True):
|
|
456
|
+
if entry.origin is None:
|
|
457
|
+
table[name] = (
|
|
458
|
+
entry.values["keys"]
|
|
459
|
+
if section.name == "keys"
|
|
460
|
+
else new_table(entry.values, inline=isinstance(table, InlineTable))
|
|
461
|
+
)
|
|
462
|
+
continue
|
|
463
|
+
origin = cast(str, entry.origin)
|
|
464
|
+
if origin in moved:
|
|
465
|
+
table[name] = moved[origin]
|
|
466
|
+
if origin not in edited:
|
|
467
|
+
continue
|
|
468
|
+
if section.name == "keys":
|
|
469
|
+
table[name] = entry.values["keys"]
|
|
470
|
+
continue
|
|
471
|
+
item = cast(MutableMapping[str, Any], table[name])
|
|
472
|
+
for label, value in entry.values.items():
|
|
473
|
+
if value != originals[origin][label]:
|
|
474
|
+
assign(item, label, value)
|
|
475
|
+
return True
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
class ConfigEditor:
|
|
479
|
+
def __init__(self, path: Path, base: Draft) -> None:
|
|
480
|
+
self.path = path
|
|
481
|
+
self.base = base
|
|
482
|
+
|
|
483
|
+
@staticmethod
|
|
484
|
+
def read(path: Path) -> tuple[str | None, tomlkit.TOMLDocument]:
|
|
485
|
+
try:
|
|
486
|
+
original = path.read_text(encoding="utf-8")
|
|
487
|
+
except FileNotFoundError:
|
|
488
|
+
original = None
|
|
489
|
+
except OSError, UnicodeError:
|
|
490
|
+
raise ConfigError(f"Could not read configuration file: {path}") from None
|
|
491
|
+
try:
|
|
492
|
+
document = tomlkit.parse(original or "")
|
|
493
|
+
except TOMLKitError:
|
|
494
|
+
raise ConfigError(
|
|
495
|
+
"Configuration file is not valid TOML; fix it with diffbutler config --edit"
|
|
496
|
+
) from None
|
|
497
|
+
try:
|
|
498
|
+
validate_document(document.unwrap())
|
|
499
|
+
except ConfigError as exc:
|
|
500
|
+
raise ConfigError(
|
|
501
|
+
f"Configuration file is invalid ({exc}); fix it with diffbutler config --edit"
|
|
502
|
+
) from None
|
|
503
|
+
return original, document
|
|
504
|
+
|
|
505
|
+
@classmethod
|
|
506
|
+
def open(cls, path: Path | None) -> ConfigEditor:
|
|
507
|
+
selected = (path or config_path()).expanduser().resolve()
|
|
508
|
+
_, document = cls.read(selected)
|
|
509
|
+
return cls(selected, project(document))
|
|
510
|
+
|
|
511
|
+
def draft(self) -> Draft:
|
|
512
|
+
return copy.deepcopy(self.base)
|
|
513
|
+
|
|
514
|
+
def save(self, draft: Draft) -> bool:
|
|
515
|
+
"""Write the edited values; False when nothing was edited."""
|
|
516
|
+
if draft == self.base:
|
|
517
|
+
return False
|
|
518
|
+
try:
|
|
519
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
520
|
+
with config_lock(self.path):
|
|
521
|
+
original, document = self.read(self.path)
|
|
522
|
+
if not apply(document, self.base, project(document), draft):
|
|
523
|
+
return False
|
|
524
|
+
content = tomlkit.dumps(document)
|
|
525
|
+
try:
|
|
526
|
+
written = tomllib.loads(content)
|
|
527
|
+
except tomllib.TOMLDecodeError:
|
|
528
|
+
raise ConfigError("Settings could not be saved as valid TOML") from None
|
|
529
|
+
validate_document(written)
|
|
530
|
+
if not publish_config(self.path, original, content):
|
|
531
|
+
raise conflict("The configuration file")
|
|
532
|
+
self.base = project(document)
|
|
533
|
+
except OSError, UnicodeError, TOMLKitError:
|
|
534
|
+
raise ConfigError(f"Could not save configuration file: {self.path}") from None
|
|
535
|
+
return True
|