davinci-resolve-mcp 3.2.0 → 3.2.2
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.
- package/CHANGELOG.md +80 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/process/release-process.md +8 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +1 -1
- package/src/utils/lut_files.py +43 -6
- package/src/utils/media_analysis.py +53 -3
- package/src/utils/timeline_brain_db.py +42 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,86 @@
|
|
|
2
2
|
|
|
3
3
|
Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
|
|
4
4
|
|
|
5
|
+
## What's New in v3.2.2 — an analysis root that is deleted is actually let go of
|
|
6
|
+
|
|
7
|
+
Contributed by @Dev-next-gen (#228), generalised to the second site and to the
|
|
8
|
+
connection cache underneath both.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **`cleanup_artifacts(frames_only=false)` reported success whether or not it
|
|
13
|
+
removed anything.** The analysis root contains
|
|
14
|
+
`_soul/timeline_brain.sqlite`, which `timeline_brain_db` keeps open in a
|
|
15
|
+
process-wide cache for the life of the server. Nothing let go of it before
|
|
16
|
+
the `shutil.rmtree`, and the rmtree runs with `ignore_errors=True`, so both
|
|
17
|
+
failure modes were swallowed. On Windows the open handle makes the DB
|
|
18
|
+
undeletable: the root survives with `_soul/` and the brain-edit history still
|
|
19
|
+
in it while the tool returns `{"success": true}`. On POSIX the root is
|
|
20
|
+
removed but the stale connection stays cached, so the next write for that
|
|
21
|
+
project goes to a file with no directory entry and is lost — the dashboard,
|
|
22
|
+
which opens the path fresh, sees nothing. `timeline_brain_db.close()` now
|
|
23
|
+
releases one project's connection, and the cleanup returns `success: false`
|
|
24
|
+
if the root is still on disk afterwards. (#228)
|
|
25
|
+
- **The same bug at a second site.** A `session_only` run without
|
|
26
|
+
`keep_artifacts` ingests every report into the brain DB under its output root
|
|
27
|
+
and then deletes that root, with the connection still cached. Because each
|
|
28
|
+
such run gets a fresh temp root, the cache accumulated one dead connection
|
|
29
|
+
per run. Both sites now go through one helper, and
|
|
30
|
+
`artifacts_cleaned_up` reports whether the removal happened rather than that
|
|
31
|
+
it was attempted.
|
|
32
|
+
- **`close()` released nothing when the root was spelled differently.** The
|
|
33
|
+
connection cache keyed on the caller's spelling of the path, and callers
|
|
34
|
+
disagree by construction: `media_analysis` realpaths a root before using it,
|
|
35
|
+
while its own callers pass what the user typed. On macOS that alone was
|
|
36
|
+
enough — every temp root under `/var/folders` is a symlink to
|
|
37
|
+
`/private/var/folders` — so `close()` computed a key that was never in the
|
|
38
|
+
cache, popped nothing, and the fix above silently did not apply. Two
|
|
39
|
+
spellings of one root also opened two connections to one SQLite file. The
|
|
40
|
+
cache now keys on the resolved DB path.
|
|
41
|
+
|
|
42
|
+
### Release process
|
|
43
|
+
|
|
44
|
+
- **`tests.test_release_surface_drift` is now in the documented gate list.** It
|
|
45
|
+
asserts the README badge, the `README.zh-CN.md` badge and translation line,
|
|
46
|
+
and a `CHANGELOG.md` entry all match `package.json` — and it was the one
|
|
47
|
+
version-surface check missing from `docs/process/release-process.md`. v3.2.1
|
|
48
|
+
shipped with a zh-CN badge still reading v3.2.0 because every documented gate
|
|
49
|
+
passed while none of them looks at a version surface. That badge is corrected
|
|
50
|
+
here.
|
|
51
|
+
|
|
52
|
+
## What's New in v3.2.1 — three correctness fixes to the LUT tool
|
|
53
|
+
|
|
54
|
+
Contributed by @Dev-next-gen (#225, #226, #227), each found by reading the v3.2.0
|
|
55
|
+
`lut` tool rather than by hitting it in use.
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
|
|
59
|
+
- **`install` reported `overwritten: true` on a fresh write.** The flag was
|
|
60
|
+
`bool(overwrite and payload is not None)`, and `payload` can never be `None`
|
|
61
|
+
where that line runs, so the field handed the caller's own `overwrite`
|
|
62
|
+
argument back instead of an observation. An install passing `overwrite=true`
|
|
63
|
+
for idempotence, landing on an empty `MCP/`, was reported as having replaced
|
|
64
|
+
existing work. `execution_lifecycle` rates `lut install` MEDIUM precisely
|
|
65
|
+
because it "can replace with overwrite=true", so this flag is what a caller
|
|
66
|
+
and the execution trace read to learn whether an install destroyed anything.
|
|
67
|
+
It is now the `os.path.exists` observation already taken one line above — the
|
|
68
|
+
measured pre-state, not the permission. (#225)
|
|
69
|
+
- **`install(source_path=...)` could not copy a binary LUT.** The copy went
|
|
70
|
+
through a UTF-8 text round-trip, so the two binary extensions this server
|
|
71
|
+
advertises — `.dat` and `.olut` — raised `UnicodeDecodeError` before anything
|
|
72
|
+
was written, surfacing as `LUT_ERROR`. So did an ordinary `.cube` whose vendor
|
|
73
|
+
wrote its `TITLE` line in latin-1. The file-copy branch is now byte-exact; the
|
|
74
|
+
text branch (`source=`) is unchanged. (#226)
|
|
75
|
+
- **`list` marked siblings of the writable subdir as writable.** The `writable`
|
|
76
|
+
flag used `realpath(current).startswith(writable_root)`, a string prefix with
|
|
77
|
+
no separator, so `MCP_old/` left by a hand backup or a vendor pack unpacking
|
|
78
|
+
as `MCPresets/` cleared it. The listing then contradicted the only tool that
|
|
79
|
+
consumes the flag: `remove` resolves names inside `MCP/` and refused the very
|
|
80
|
+
`set_lut_path` the listing had just handed out. It now compares by path
|
|
81
|
+
segment with `commonpath`, which is what `_is_relative_to` and `resolve_writable`
|
|
82
|
+
already use elsewhere in this repo. Nothing that worked before stops working —
|
|
83
|
+
the flag only flips for paths `remove` was already refusing. (#227)
|
|
84
|
+
|
|
5
85
|
## What's New in v3.2.0 — LUT files: find them, install them, remove them, gated
|
|
6
86
|
|
|
7
87
|
Contributed by @legionsound (#223), live-validated on Studio 21.1.0.14.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
English | [简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#server-modes)
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v3.2.
|
|
15
|
+
> 本翻译对应 v3.2.2 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
|
|
16
16
|
|
|
17
17
|
一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
|
|
18
18
|
|
|
@@ -75,7 +75,7 @@ venv/bin/python scripts/audit_api_parity.py
|
|
|
75
75
|
venv/bin/python scripts/gen_api_limitations.py --check
|
|
76
76
|
venv/bin/python scripts/audit_readwrite_symmetry.py --check
|
|
77
77
|
node scripts/agent-rules/generate.mjs --check
|
|
78
|
-
venv/bin/python -m unittest tests.test_static_undefined_names tests.test_duplicate_definitions tests.test_action_list_drift tests.test_panel_docs_drift tests.test_doc_tool_counts tests.test_agent_rules_drift
|
|
78
|
+
venv/bin/python -m unittest tests.test_static_undefined_names tests.test_duplicate_definitions tests.test_action_list_drift tests.test_panel_docs_drift tests.test_doc_tool_counts tests.test_agent_rules_drift tests.test_release_surface_drift
|
|
79
79
|
node bin/davinci-resolve-mcp.mjs --help
|
|
80
80
|
node bin/davinci-resolve-mcp.mjs --version
|
|
81
81
|
npm pack --dry-run
|
|
@@ -89,6 +89,13 @@ regeneration is in the working tree when the test reads it — that ordering is
|
|
|
89
89
|
the check is a regeneration followed by a test, not a `git diff --exit-code`,
|
|
90
90
|
which would fire on the release bump's own legitimate change.
|
|
91
91
|
|
|
92
|
+
`test_release_surface_drift` is the gate on the version bump itself: it asserts
|
|
93
|
+
the README badge, the `README.zh-CN.md` badge and its `本翻译对应 vX.Y.Z` line, and
|
|
94
|
+
a `CHANGELOG.md` entry all match `package.json`. It was not in this list until
|
|
95
|
+
v3.2.2, and v3.2.1 shipped with a zh-CN badge still reading v3.2.0 as a direct
|
|
96
|
+
result — every other gate passed, because none of them looks at a version
|
|
97
|
+
surface. Run it before tagging, not after.
|
|
98
|
+
|
|
92
99
|
`test_duplicate_definitions` asserts no module-level name is defined twice under
|
|
93
100
|
`src/`. A second `def foo` silently replaces the first, and in a module the size
|
|
94
101
|
of `src/server.py` the two can be thousands of lines apart with different
|
package/install.py
CHANGED
|
@@ -37,7 +37,7 @@ from src.utils.update_check import (
|
|
|
37
37
|
|
|
38
38
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
39
39
|
|
|
40
|
-
VERSION = "3.2.
|
|
40
|
+
VERSION = "3.2.2"
|
|
41
41
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
42
42
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
43
43
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
package/src/granular/common.py
CHANGED
|
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
|
|
|
87
87
|
handlers=[logging.StreamHandler()],
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
VERSION = "3.2.
|
|
90
|
+
VERSION = "3.2.2"
|
|
91
91
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
92
92
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
93
93
|
logger.info(f"Detected platform: {get_platform()}")
|
package/src/server.py
CHANGED
package/src/utils/lut_files.py
CHANGED
|
@@ -50,6 +50,20 @@ def writable_dir() -> str:
|
|
|
50
50
|
return os.path.join(master_lut_dir(), WRITABLE_SUBDIR)
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
def _is_within(path: str, parent: str) -> bool:
|
|
54
|
+
"""Whether ``path`` is ``parent`` itself or sits under it, compared by segment.
|
|
55
|
+
|
|
56
|
+
``commonpath`` is the comparison the rest of the repo uses for confinement
|
|
57
|
+
checks (`media_analysis._is_relative_to`), and it is the only one that is
|
|
58
|
+
correct here: a string prefix test also accepts a sibling whose name merely
|
|
59
|
+
starts with the same characters.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
return os.path.commonpath([path, parent]) == parent
|
|
63
|
+
except (ValueError, OSError):
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
53
67
|
def normalize_relative(name: str, *, default_ext: Optional[str] = None) -> str:
|
|
54
68
|
"""Validate a caller-supplied LUT name and return it as a POSIX relative path.
|
|
55
69
|
|
|
@@ -123,7 +137,13 @@ def list_luts(subdir: Optional[str] = None) -> Dict[str, Any]:
|
|
|
123
137
|
except OSError:
|
|
124
138
|
size = None
|
|
125
139
|
try:
|
|
126
|
-
|
|
140
|
+
# Segment-wise, not a string prefix: a sibling of the writable
|
|
141
|
+
# subdir whose name starts with it -- MCP_old/ after a manual
|
|
142
|
+
# backup, MCPresets/ from a vendor pack -- cleared
|
|
143
|
+
# `startswith(writable_root)` and was listed as writable, while
|
|
144
|
+
# `remove` resolves names under MCP/ and refuses the very path
|
|
145
|
+
# the listing handed back.
|
|
146
|
+
is_writable = _is_within(os.path.realpath(current), writable_root)
|
|
127
147
|
except OSError:
|
|
128
148
|
is_writable = False
|
|
129
149
|
found.append({
|
|
@@ -154,28 +174,45 @@ def install_lut(name: str, *, source: Optional[str] = None,
|
|
|
154
174
|
if (source is None) == (source_path is None):
|
|
155
175
|
raise LutPathError("provide exactly one of source (text) or source_path (a file to copy)")
|
|
156
176
|
absolute, set_lut_path = resolve_writable(name)
|
|
157
|
-
|
|
177
|
+
existed = os.path.exists(absolute)
|
|
178
|
+
if existed and not overwrite:
|
|
158
179
|
raise LutPathError(
|
|
159
180
|
f"{set_lut_path} already exists. Pass overwrite=true to replace it."
|
|
160
181
|
)
|
|
161
182
|
if source_path is not None:
|
|
162
183
|
if not os.path.isfile(source_path):
|
|
163
184
|
raise LutPathError(f"source_path not found: {source_path}")
|
|
164
|
-
|
|
185
|
+
# A copy has to be byte-exact. Two of the extensions this server
|
|
186
|
+
# advertises -- .dat and .olut -- are binary, and a .cube carries its
|
|
187
|
+
# TITLE in whatever encoding the vendor wrote it in, so decoding the
|
|
188
|
+
# source as UTF-8 turned "install the LUT I already have on disk" into
|
|
189
|
+
# a UnicodeDecodeError surfacing as LUT_ERROR.
|
|
190
|
+
with open(source_path, "rb") as handle:
|
|
165
191
|
payload = handle.read()
|
|
166
192
|
else:
|
|
167
193
|
payload = source
|
|
168
194
|
if not payload.strip():
|
|
169
195
|
raise LutPathError("refusing to install an empty LUT")
|
|
170
196
|
os.makedirs(os.path.dirname(absolute), exist_ok=True)
|
|
171
|
-
|
|
172
|
-
|
|
197
|
+
if isinstance(payload, bytes):
|
|
198
|
+
with open(absolute, "wb") as handle:
|
|
199
|
+
handle.write(payload)
|
|
200
|
+
else:
|
|
201
|
+
with open(absolute, "w", encoding="utf-8") as handle:
|
|
202
|
+
handle.write(payload)
|
|
173
203
|
return {
|
|
174
204
|
"success": True,
|
|
175
205
|
"path": absolute,
|
|
176
206
|
"set_lut_path": set_lut_path,
|
|
177
207
|
"bytes": os.path.getsize(absolute),
|
|
178
|
-
|
|
208
|
+
# Whether a file was actually replaced, not whether the caller allowed
|
|
209
|
+
# it: `payload is not None` is always true here, so this reported a
|
|
210
|
+
# replacement for every overwrite=true install, including the ones that
|
|
211
|
+
# landed on an empty MCP/. This flag is the record of what an install
|
|
212
|
+
# destroyed -- execution_lifecycle rates `lut install` on the fact that
|
|
213
|
+
# it "can replace with overwrite=true" -- so it has to be the observed
|
|
214
|
+
# pre-state, not the permission.
|
|
215
|
+
"overwritten": existed,
|
|
179
216
|
"note": ("Call project_settings(action='refresh_luts') so Resolve picks up "
|
|
180
217
|
"the new file before applying it."),
|
|
181
218
|
}
|
|
@@ -2590,6 +2590,48 @@ def _read_json(path: str) -> Dict[str, Any]:
|
|
|
2590
2590
|
return json.load(f)
|
|
2591
2591
|
|
|
2592
2592
|
|
|
2593
|
+
def _drop_brain_db_and_rmtree(project_root: str, cleanup_root: str) -> bool:
|
|
2594
|
+
"""Delete `cleanup_root`, releasing `project_root`'s brain DB first.
|
|
2595
|
+
|
|
2596
|
+
`<project_root>/_soul/timeline_brain.sqlite` is held open in a
|
|
2597
|
+
process-wide cache for the life of the server, and nothing else lets go of
|
|
2598
|
+
it. Deleting the root underneath that handle fails differently on each
|
|
2599
|
+
platform and silently on both, because rmtree runs with
|
|
2600
|
+
``ignore_errors=True``:
|
|
2601
|
+
|
|
2602
|
+
- Windows refuses to delete the open file, so the root survives with
|
|
2603
|
+
`_soul/` and the brain-edit history still in it while the caller is told
|
|
2604
|
+
it is gone.
|
|
2605
|
+
- POSIX unlinks it, but the cache then hands the next writer a connection
|
|
2606
|
+
to a file with no directory entry, so the write lands nowhere.
|
|
2607
|
+
|
|
2608
|
+
Returns whether `cleanup_root` is actually gone afterwards, so callers can
|
|
2609
|
+
report a removal that happened rather than one they attempted.
|
|
2610
|
+
"""
|
|
2611
|
+
from src.utils import timeline_brain_db as _brain_db
|
|
2612
|
+
|
|
2613
|
+
_brain_db.close(project_root)
|
|
2614
|
+
shutil.rmtree(cleanup_root, ignore_errors=True)
|
|
2615
|
+
return not os.path.isdir(cleanup_root)
|
|
2616
|
+
|
|
2617
|
+
|
|
2618
|
+
def _release_session_root(manifest: Dict[str, Any], project_root: str,
|
|
2619
|
+
cleanup_root: str) -> bool:
|
|
2620
|
+
"""Session-only artifact cleanup: same rule as `cleanup_artifacts`.
|
|
2621
|
+
|
|
2622
|
+
A session-only run ingests every report into the brain DB under
|
|
2623
|
+
`project_root` and then throws the root away, and each run gets a fresh
|
|
2624
|
+
temp root -- so without the release the cache accumulates one dead
|
|
2625
|
+
connection per run.
|
|
2626
|
+
"""
|
|
2627
|
+
removed = _drop_brain_db_and_rmtree(project_root, cleanup_root)
|
|
2628
|
+
if not removed:
|
|
2629
|
+
manifest.setdefault("memory_layer_warnings", []).append(
|
|
2630
|
+
f"Could not fully remove the session analysis root: {cleanup_root}"
|
|
2631
|
+
)
|
|
2632
|
+
return removed
|
|
2633
|
+
|
|
2634
|
+
|
|
2593
2635
|
def _ingest_report_into_db(project_root: str, report: Dict[str, Any], clip_dir: Optional[str]) -> Dict[str, Any]:
|
|
2594
2636
|
"""C1 — write a report into the DB-canonical store (rows in a transaction).
|
|
2595
2637
|
|
|
@@ -5971,8 +6013,9 @@ async def execute_plan_async(
|
|
|
5971
6013
|
and _is_relative_to(output_root, candidate)
|
|
5972
6014
|
):
|
|
5973
6015
|
cleanup_root = candidate
|
|
5974
|
-
|
|
5975
|
-
|
|
6016
|
+
manifest["artifacts_cleaned_up"] = _release_session_root(
|
|
6017
|
+
manifest, output_root, cleanup_root
|
|
6018
|
+
)
|
|
5976
6019
|
manifest["artifact_cleanup_root"] = cleanup_root
|
|
5977
6020
|
|
|
5978
6021
|
return manifest
|
|
@@ -7057,7 +7100,14 @@ def cleanup_artifacts(project_root: str, *, frames_only: bool = True) -> Dict[st
|
|
|
7057
7100
|
shutil.rmtree(full, ignore_errors=True)
|
|
7058
7101
|
removed.append(full)
|
|
7059
7102
|
else:
|
|
7060
|
-
|
|
7103
|
+
# The whole root goes, including `_soul/timeline_brain.sqlite`.
|
|
7104
|
+
if not _drop_brain_db_and_rmtree(root, root):
|
|
7105
|
+
return {
|
|
7106
|
+
"success": False,
|
|
7107
|
+
"error": f"Could not fully remove the project analysis root: {root}",
|
|
7108
|
+
"removed": removed,
|
|
7109
|
+
"frames_only": frames_only,
|
|
7110
|
+
}
|
|
7061
7111
|
removed.append(root)
|
|
7062
7112
|
return {"success": True, "removed": removed, "frames_only": frames_only}
|
|
7063
7113
|
|
|
@@ -50,6 +50,26 @@ def db_path_for_project(project_root: str) -> str:
|
|
|
50
50
|
return os.path.join(project_root, SOUL_DIRNAME, DB_FILENAME)
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
def _cache_key(project_root: str) -> str:
|
|
54
|
+
"""The connection-cache key for a project root: its DB path, realpath'd.
|
|
55
|
+
|
|
56
|
+
Two spellings of one root must not become two cached connections to one
|
|
57
|
+
file. They do without this, because callers disagree about spelling by
|
|
58
|
+
construction -- `media_analysis` normalizes a root through `realpath`
|
|
59
|
+
before using it, while its own callers pass whatever the user typed. On
|
|
60
|
+
macOS that alone is enough: every temp root under `/var/folders/...` is a
|
|
61
|
+
symlink to `/private/var/folders/...`.
|
|
62
|
+
|
|
63
|
+
The damage is not just a duplicate. `close()` pops by key, so a mismatch
|
|
64
|
+
makes it silently no-op and leave the connection it was called to release
|
|
65
|
+
-- which is the whole point of calling it before deleting the root.
|
|
66
|
+
|
|
67
|
+
`realpath` on a path that does not exist yet resolves the ancestors that do
|
|
68
|
+
and leaves the rest literal, which is what a not-yet-created DB needs.
|
|
69
|
+
"""
|
|
70
|
+
return os.path.realpath(db_path_for_project(project_root))
|
|
71
|
+
|
|
72
|
+
|
|
53
73
|
def _ensure_parent_dir(path: str) -> None:
|
|
54
74
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
55
75
|
|
|
@@ -224,7 +244,7 @@ def connect(project_root: str) -> sqlite3.Connection:
|
|
|
224
244
|
"""
|
|
225
245
|
if not project_root:
|
|
226
246
|
raise ValueError("project_root is required")
|
|
227
|
-
path =
|
|
247
|
+
path = _cache_key(project_root)
|
|
228
248
|
with _CONNECTION_LOCK:
|
|
229
249
|
existing = _CONNECTIONS.get(path)
|
|
230
250
|
if existing is not None:
|
|
@@ -250,6 +270,25 @@ def close_all() -> None:
|
|
|
250
270
|
_CONNECTIONS.clear()
|
|
251
271
|
|
|
252
272
|
|
|
273
|
+
def close(project_root: str) -> None:
|
|
274
|
+
"""Drop and close the cached connection for `project_root`, if any.
|
|
275
|
+
|
|
276
|
+
Callers that are about to delete or move a project's analysis root need
|
|
277
|
+
this. On Windows the open handle makes `_soul/timeline_brain.sqlite`
|
|
278
|
+
undeletable; on POSIX the cache would otherwise hand the next writer a
|
|
279
|
+
connection to a file that no longer has a directory entry, so the write
|
|
280
|
+
lands nowhere.
|
|
281
|
+
"""
|
|
282
|
+
path = _cache_key(project_root)
|
|
283
|
+
with _CONNECTION_LOCK:
|
|
284
|
+
conn = _CONNECTIONS.pop(path, None)
|
|
285
|
+
if conn is not None:
|
|
286
|
+
try:
|
|
287
|
+
conn.close()
|
|
288
|
+
except sqlite3.Error:
|
|
289
|
+
pass
|
|
290
|
+
|
|
291
|
+
|
|
253
292
|
@contextmanager
|
|
254
293
|
def transaction(project_root: str) -> Iterator[sqlite3.Connection]:
|
|
255
294
|
"""Context manager wrapping a write transaction.
|
|
@@ -291,14 +330,8 @@ def transaction(project_root: str) -> Iterator[sqlite3.Connection]:
|
|
|
291
330
|
|
|
292
331
|
def reset_for_test(project_root: str) -> None:
|
|
293
332
|
"""Drop + recreate every table. Tests only."""
|
|
294
|
-
path =
|
|
295
|
-
|
|
296
|
-
conn = _CONNECTIONS.pop(path, None)
|
|
297
|
-
if conn is not None:
|
|
298
|
-
try:
|
|
299
|
-
conn.close()
|
|
300
|
-
except sqlite3.Error:
|
|
301
|
-
pass
|
|
333
|
+
path = _cache_key(project_root)
|
|
334
|
+
close(project_root)
|
|
302
335
|
for suffix in ("", "-wal", "-shm"):
|
|
303
336
|
try:
|
|
304
337
|
os.remove(path + suffix)
|