mdb-cli 0.1.0__tar.gz → 0.1.1__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.
- {mdb_cli-0.1.0 → mdb_cli-0.1.1}/.gitignore +3 -0
- {mdb_cli-0.1.0 → mdb_cli-0.1.1}/PKG-INFO +34 -9
- {mdb_cli-0.1.0 → mdb_cli-0.1.1}/README.md +33 -8
- mdb_cli-0.1.1/mdb/config.py +198 -0
- {mdb_cli-0.1.0 → mdb_cli-0.1.1}/mdb/data/SKILL.md +13 -0
- {mdb_cli-0.1.0 → mdb_cli-0.1.1}/mdb/discovery.py +1 -1
- {mdb_cli-0.1.0 → mdb_cli-0.1.1}/mdb/filelock.py +20 -5
- {mdb_cli-0.1.0 → mdb_cli-0.1.1}/mdb/mdb.py +356 -254
- {mdb_cli-0.1.0 → mdb_cli-0.1.1}/pyproject.toml +1 -1
- {mdb_cli-0.1.0 → mdb_cli-0.1.1}/LICENSE +0 -0
- {mdb_cli-0.1.0 → mdb_cli-0.1.1}/mdb/__init__.py +0 -0
- {mdb_cli-0.1.0 → mdb_cli-0.1.1}/mdb/atomic.py +0 -0
- {mdb_cli-0.1.0 → mdb_cli-0.1.1}/mdb/data/__init__.py +0 -0
- {mdb_cli-0.1.0 → mdb_cli-0.1.1}/mdb/formatter.py +0 -0
- {mdb_cli-0.1.0 → mdb_cli-0.1.1}/mdb/init.py +0 -0
- {mdb_cli-0.1.0 → mdb_cli-0.1.1}/mdb/models.py +0 -0
- {mdb_cli-0.1.0 → mdb_cli-0.1.1}/mdb/parser.py +0 -0
- {mdb_cli-0.1.0 → mdb_cli-0.1.1}/mdb/puller.py +0 -0
- {mdb_cli-0.1.0 → mdb_cli-0.1.1}/mdb/validators.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mdb-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Markdown table workflows w/ SQLite powers ✨
|
|
5
5
|
Project-URL: Homepage, https://atomanoid.github.io/mdb/
|
|
6
6
|
Project-URL: Repository, https://github.com/atomanoid/mdb
|
|
@@ -89,15 +89,16 @@ Place inline query markers above tables in your markdown file using the followin
|
|
|
89
89
|
| `🌀` | feed | Include the table below me as part of the dataset |
|
|
90
90
|
| `💎` | tap | Execute my SQL query on the dataset and render the results as the table below me (table auto-generated if absent) |
|
|
91
91
|
|
|
92
|
-
> Under the hood, datasets are
|
|
92
|
+
> Under the hood, datasets are dynamically backed by SQLite databases on which SQL queries are actually run 🚀
|
|
93
93
|
|
|
94
94
|
### Subcommands
|
|
95
95
|
|
|
96
|
-
| Subcommand | Behavior
|
|
97
|
-
| ------------------------- |
|
|
98
|
-
| `mdb
|
|
99
|
-
| `mdb push
|
|
100
|
-
| `mdb
|
|
96
|
+
| Subcommand | Behavior |
|
|
97
|
+
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
98
|
+
| `mdb push` | process all `🌀` markers first (pulls), then all `💎` markers after (pushes) — reports only changed markers by default |
|
|
99
|
+
| `mdb push <sql-mutation>` | process all `🌀` markers first (pulls), execute the mutation, then all `🌀` and `💎` markers after (pushes) |
|
|
100
|
+
| `mdb pull <sql-query>` | process all `🌀` markers first (pulls), then outputs SQL query results in CSV format |
|
|
101
|
+
| `mdb status` | inspects all `🌀` and `💎` markers, then reports metadata and warnings |
|
|
101
102
|
|
|
102
103
|
### Example
|
|
103
104
|
|
|
@@ -181,7 +182,9 @@ We can also mutate data directly. Running:
|
|
|
181
182
|
mdb push "UPDATE snacks SET qty = qty + 20 WHERE name = 'Takis'"
|
|
182
183
|
```
|
|
183
184
|
|
|
184
|
-
The 🌀 (feed) marker again ingests the snacks data into the dataset, applies the UPDATE, then both 🌀 (feed) and 💎 (tap) markers push fresh results back
|
|
185
|
+
The 🌀 (feed) marker again ingests the snacks data into the dataset, applies the UPDATE, then both 🌀 (feed) and 💎 (tap) markers push fresh results back out.
|
|
186
|
+
|
|
187
|
+
So then in the updated `snacks.md`:
|
|
185
188
|
|
|
186
189
|
```markdown
|
|
187
190
|
## Inventory
|
|
@@ -206,11 +209,29 @@ The 🌀 (feed) marker again ingests the snacks data into the dataset, applies t
|
|
|
206
209
|
|
|
207
210
|
Takis got restocked to 27 and dropped off the low-stock list — all from a single command.
|
|
208
211
|
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
We can also inspect the current landscape of markers. Running:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
mdb status
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
We'll get a snapshot of the available data:
|
|
221
|
+
|
|
222
|
+
```
|
|
223
|
+
OK 🌀 snacks (3 cols, 4 rows) [snacks.md:5]
|
|
224
|
+
OK 💎 SELECT name, qty FROM snacks WHERE qty < 10 ORDER BY qty (2 cols, 2 rows) [snacks.md:15]
|
|
225
|
+
Inspected 1 files, 2 markers: 0 problems
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
🌀 (feed) and 💎 (tap) markers show table dimensions, query text — and any problems (missing tables, conflicts) are flagged.
|
|
229
|
+
|
|
209
230
|
## Agent Support
|
|
210
231
|
|
|
211
232
|
`mdb-cli` includes a built-in agent skill. Install it:
|
|
212
233
|
|
|
213
|
-
```
|
|
234
|
+
```bash
|
|
214
235
|
mdb init # default: .mdb/skills/mdb/SKILL.md
|
|
215
236
|
mdb init .claude/skills # Claude Code: .claude/skills/mdb/SKILL.md
|
|
216
237
|
mdb init .opencode/skills # OpenCode: .opencode/skills/mdb/SKILL.md
|
|
@@ -218,3 +239,7 @@ mdb init path/to/agent/skills # any agent: path/to/agent/skills/mdb/SKILL.md
|
|
|
218
239
|
```
|
|
219
240
|
|
|
220
241
|
Within a session, invoke the `/mdb` slash command to access a comprehensive reference covering marker syntax, subcommand workflows, templates for common operations, and an error reference guide. The skill provides everything an AI coding assistant needs to construct and debug mdb markers without leaving the editor.
|
|
242
|
+
|
|
243
|
+
## Configuration
|
|
244
|
+
|
|
245
|
+
`mdb init` also generates a `.mdbrc` configuration file in the project root. Edit it to set project-level defaults for CLI flags (`include`, `compaction`, `verbose`). CLI flags always override `.mdbrc` values. See [CLI Usage](https://atomanoid.github.io/mdb/cli-usage/) for details.
|
|
@@ -61,15 +61,16 @@ Place inline query markers above tables in your markdown file using the followin
|
|
|
61
61
|
| `🌀` | feed | Include the table below me as part of the dataset |
|
|
62
62
|
| `💎` | tap | Execute my SQL query on the dataset and render the results as the table below me (table auto-generated if absent) |
|
|
63
63
|
|
|
64
|
-
> Under the hood, datasets are
|
|
64
|
+
> Under the hood, datasets are dynamically backed by SQLite databases on which SQL queries are actually run 🚀
|
|
65
65
|
|
|
66
66
|
### Subcommands
|
|
67
67
|
|
|
68
|
-
| Subcommand | Behavior
|
|
69
|
-
| ------------------------- |
|
|
70
|
-
| `mdb
|
|
71
|
-
| `mdb push
|
|
72
|
-
| `mdb
|
|
68
|
+
| Subcommand | Behavior |
|
|
69
|
+
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
70
|
+
| `mdb push` | process all `🌀` markers first (pulls), then all `💎` markers after (pushes) — reports only changed markers by default |
|
|
71
|
+
| `mdb push <sql-mutation>` | process all `🌀` markers first (pulls), execute the mutation, then all `🌀` and `💎` markers after (pushes) |
|
|
72
|
+
| `mdb pull <sql-query>` | process all `🌀` markers first (pulls), then outputs SQL query results in CSV format |
|
|
73
|
+
| `mdb status` | inspects all `🌀` and `💎` markers, then reports metadata and warnings |
|
|
73
74
|
|
|
74
75
|
### Example
|
|
75
76
|
|
|
@@ -153,7 +154,9 @@ We can also mutate data directly. Running:
|
|
|
153
154
|
mdb push "UPDATE snacks SET qty = qty + 20 WHERE name = 'Takis'"
|
|
154
155
|
```
|
|
155
156
|
|
|
156
|
-
The 🌀 (feed) marker again ingests the snacks data into the dataset, applies the UPDATE, then both 🌀 (feed) and 💎 (tap) markers push fresh results back
|
|
157
|
+
The 🌀 (feed) marker again ingests the snacks data into the dataset, applies the UPDATE, then both 🌀 (feed) and 💎 (tap) markers push fresh results back out.
|
|
158
|
+
|
|
159
|
+
So then in the updated `snacks.md`:
|
|
157
160
|
|
|
158
161
|
```markdown
|
|
159
162
|
## Inventory
|
|
@@ -178,11 +181,29 @@ The 🌀 (feed) marker again ingests the snacks data into the dataset, applies t
|
|
|
178
181
|
|
|
179
182
|
Takis got restocked to 27 and dropped off the low-stock list — all from a single command.
|
|
180
183
|
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
We can also inspect the current landscape of markers. Running:
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
mdb status
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
We'll get a snapshot of the available data:
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
OK 🌀 snacks (3 cols, 4 rows) [snacks.md:5]
|
|
196
|
+
OK 💎 SELECT name, qty FROM snacks WHERE qty < 10 ORDER BY qty (2 cols, 2 rows) [snacks.md:15]
|
|
197
|
+
Inspected 1 files, 2 markers: 0 problems
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
🌀 (feed) and 💎 (tap) markers show table dimensions, query text — and any problems (missing tables, conflicts) are flagged.
|
|
201
|
+
|
|
181
202
|
## Agent Support
|
|
182
203
|
|
|
183
204
|
`mdb-cli` includes a built-in agent skill. Install it:
|
|
184
205
|
|
|
185
|
-
```
|
|
206
|
+
```bash
|
|
186
207
|
mdb init # default: .mdb/skills/mdb/SKILL.md
|
|
187
208
|
mdb init .claude/skills # Claude Code: .claude/skills/mdb/SKILL.md
|
|
188
209
|
mdb init .opencode/skills # OpenCode: .opencode/skills/mdb/SKILL.md
|
|
@@ -190,3 +211,7 @@ mdb init path/to/agent/skills # any agent: path/to/agent/skills/mdb/SKILL.md
|
|
|
190
211
|
```
|
|
191
212
|
|
|
192
213
|
Within a session, invoke the `/mdb` slash command to access a comprehensive reference covering marker syntax, subcommand workflows, templates for common operations, and an error reference guide. The skill provides everything an AI coding assistant needs to construct and debug mdb markers without leaving the editor.
|
|
214
|
+
|
|
215
|
+
## Configuration
|
|
216
|
+
|
|
217
|
+
`mdb init` also generates a `.mdbrc` configuration file in the project root. Edit it to set project-level defaults for CLI flags (`include`, `compaction`, `verbose`). CLI flags always override `.mdbrc` values. See [CLI Usage](https://atomanoid.github.io/mdb/cli-usage/) for details.
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Configuration file (.mdbrc) support for mdb.
|
|
2
|
+
|
|
3
|
+
Handles JSONC parsing, config loading/validation, and template generation
|
|
4
|
+
for the .mdbrc project-level configuration file.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class MdbConfig:
|
|
15
|
+
"""Parsed .mdbrc configuration."""
|
|
16
|
+
include: str | None = None
|
|
17
|
+
compaction: str = "full"
|
|
18
|
+
verbose: bool = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
MDBRC_TEMPLATE = """\
|
|
22
|
+
{
|
|
23
|
+
// \u2699\ufe0f uncomment below to change defaults
|
|
24
|
+
|
|
25
|
+
// "include": "**/*.md", // (string|null)
|
|
26
|
+
// "compaction": "full", // ("full"|"fit")
|
|
27
|
+
// "verbose": false, // (bool, default false)
|
|
28
|
+
}
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
_RECOGNIZED_KEYS = {"include", "compaction", "verbose"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def strip_jsonc_comments(text: str) -> str:
|
|
35
|
+
"""Remove // line comments from JSONC text, preserving // inside strings.
|
|
36
|
+
|
|
37
|
+
Also strips trailing commas before } and ] to handle JSONC trailing comma
|
|
38
|
+
syntax that json.loads() rejects.
|
|
39
|
+
|
|
40
|
+
Uses a character-by-character state machine to correctly handle string
|
|
41
|
+
literals containing // sequences.
|
|
42
|
+
"""
|
|
43
|
+
result = []
|
|
44
|
+
i = 0
|
|
45
|
+
in_string = False
|
|
46
|
+
length = len(text)
|
|
47
|
+
|
|
48
|
+
while i < length:
|
|
49
|
+
ch = text[i]
|
|
50
|
+
|
|
51
|
+
if in_string:
|
|
52
|
+
result.append(ch)
|
|
53
|
+
if ch == '\\' and i + 1 < length:
|
|
54
|
+
# Escaped character inside string -- consume next char too
|
|
55
|
+
i += 1
|
|
56
|
+
result.append(text[i])
|
|
57
|
+
elif ch == '"':
|
|
58
|
+
in_string = False
|
|
59
|
+
i += 1
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
# Outside string
|
|
63
|
+
if ch == '"':
|
|
64
|
+
in_string = True
|
|
65
|
+
result.append(ch)
|
|
66
|
+
i += 1
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
if ch == '/' and i + 1 < length and text[i + 1] == '/':
|
|
70
|
+
# Line comment -- skip to end of line
|
|
71
|
+
while i < length and text[i] != '\n':
|
|
72
|
+
i += 1
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
result.append(ch)
|
|
76
|
+
i += 1
|
|
77
|
+
|
|
78
|
+
stripped = ''.join(result)
|
|
79
|
+
|
|
80
|
+
# Remove trailing commas before } and ]
|
|
81
|
+
# Match: comma, optional whitespace/newlines, then } or ]
|
|
82
|
+
import re
|
|
83
|
+
stripped = re.sub(r',(\s*[}\]])', r'\1', stripped)
|
|
84
|
+
|
|
85
|
+
return stripped
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def load_config(cwd: str | None = None) -> 'MdbConfig | None':
|
|
89
|
+
"""Load and validate .mdbrc from the given directory.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
cwd: Directory to search for .mdbrc. Defaults to os.getcwd().
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
MdbConfig with parsed values, or None if no .mdbrc exists.
|
|
96
|
+
Calls sys.exit(1) on hard errors (invalid JSON, bad values, etc.).
|
|
97
|
+
"""
|
|
98
|
+
config_dir = cwd or os.getcwd()
|
|
99
|
+
config_path = os.path.join(config_dir, ".mdbrc")
|
|
100
|
+
|
|
101
|
+
if not os.path.exists(config_path):
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
# FR-016: Must be a regular file
|
|
105
|
+
if not os.path.isfile(config_path):
|
|
106
|
+
print("Error: .mdbrc is not a regular file", file=sys.stderr)
|
|
107
|
+
sys.exit(1)
|
|
108
|
+
|
|
109
|
+
# FR-017: Must be valid UTF-8
|
|
110
|
+
try:
|
|
111
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
112
|
+
raw = f.read()
|
|
113
|
+
except UnicodeDecodeError:
|
|
114
|
+
print("Error: .mdbrc contains non-UTF-8 content", file=sys.stderr)
|
|
115
|
+
sys.exit(1)
|
|
116
|
+
except OSError as e:
|
|
117
|
+
print(f"Error: Cannot read .mdbrc: {e}", file=sys.stderr)
|
|
118
|
+
sys.exit(1)
|
|
119
|
+
|
|
120
|
+
# FR-007: Announce config discovery
|
|
121
|
+
print("Using .mdbrc", file=sys.stderr)
|
|
122
|
+
|
|
123
|
+
# Strip JSONC comments
|
|
124
|
+
stripped = strip_jsonc_comments(raw)
|
|
125
|
+
|
|
126
|
+
# FR-015: Empty/comments-only treated as empty object
|
|
127
|
+
if not stripped.strip():
|
|
128
|
+
return MdbConfig()
|
|
129
|
+
|
|
130
|
+
# FR-011: Parse JSON
|
|
131
|
+
try:
|
|
132
|
+
data = json.loads(stripped)
|
|
133
|
+
except json.JSONDecodeError as e:
|
|
134
|
+
print(f"Error: .mdbrc contains invalid JSON: {e}", file=sys.stderr)
|
|
135
|
+
sys.exit(1)
|
|
136
|
+
|
|
137
|
+
# CHK023: Root must be an object (dict)
|
|
138
|
+
if not isinstance(data, dict):
|
|
139
|
+
print("Error: .mdbrc root must be a JSON object", file=sys.stderr)
|
|
140
|
+
sys.exit(1)
|
|
141
|
+
|
|
142
|
+
# FR-012: Warn on unrecognized keys
|
|
143
|
+
for key in data:
|
|
144
|
+
if key not in _RECOGNIZED_KEYS:
|
|
145
|
+
print(f"Warning: .mdbrc contains unrecognized key: {key}", file=sys.stderr)
|
|
146
|
+
|
|
147
|
+
# FR-013: Validate recognized keys
|
|
148
|
+
config = MdbConfig()
|
|
149
|
+
|
|
150
|
+
if "include" in data:
|
|
151
|
+
val = data["include"]
|
|
152
|
+
if val is not None and not isinstance(val, str):
|
|
153
|
+
print("Error: .mdbrc 'include' must be a string or null", file=sys.stderr)
|
|
154
|
+
sys.exit(1)
|
|
155
|
+
config.include = val
|
|
156
|
+
|
|
157
|
+
if "compaction" in data:
|
|
158
|
+
val = data["compaction"]
|
|
159
|
+
if val not in ("full", "fit"):
|
|
160
|
+
print(f"Error: .mdbrc 'compaction' must be \"full\" or \"fit\", got: {val!r}", file=sys.stderr)
|
|
161
|
+
sys.exit(1)
|
|
162
|
+
config.compaction = val
|
|
163
|
+
|
|
164
|
+
if "verbose" in data:
|
|
165
|
+
val = data["verbose"]
|
|
166
|
+
if not isinstance(val, bool):
|
|
167
|
+
print(f"Error: .mdbrc 'verbose' must be a boolean, got: {val!r}", file=sys.stderr)
|
|
168
|
+
sys.exit(1)
|
|
169
|
+
config.verbose = val
|
|
170
|
+
|
|
171
|
+
return config
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def generate_mdbrc(cwd: str | None = None) -> bool:
|
|
175
|
+
"""Generate .mdbrc template file in the given directory.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
cwd: Directory to write .mdbrc into. Defaults to os.getcwd().
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
True on success (created or preserved), False on error.
|
|
182
|
+
"""
|
|
183
|
+
config_dir = cwd or os.getcwd()
|
|
184
|
+
config_path = os.path.join(config_dir, ".mdbrc")
|
|
185
|
+
|
|
186
|
+
# FR-003: Never overwrite existing
|
|
187
|
+
if os.path.exists(config_path):
|
|
188
|
+
print(".mdbrc already exists (preserved)")
|
|
189
|
+
return True
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
with open(config_path, "w", encoding="utf-8") as f:
|
|
193
|
+
f.write(MDBRC_TEMPLATE)
|
|
194
|
+
print("Created .mdbrc")
|
|
195
|
+
return True
|
|
196
|
+
except OSError as e:
|
|
197
|
+
print(f"Error: Cannot write .mdbrc: {e}", file=sys.stderr)
|
|
198
|
+
return False
|
|
@@ -51,10 +51,23 @@ mdb pull -i "docs/" "SELECT * FROM t" # restrict to directo
|
|
|
51
51
|
|
|
52
52
|
Pull rules: only SELECT permitted; DML rejected.
|
|
53
53
|
|
|
54
|
+
### `mdb status`
|
|
55
|
+
|
|
56
|
+
Read-only marker inventory. Reports structural metadata and detects problems
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
mdb status # scan all markdown files
|
|
60
|
+
mdb status -i "docs/" # restrict to directory
|
|
61
|
+
```
|
|
62
|
+
|
|
54
63
|
### Common flags
|
|
55
64
|
|
|
56
65
|
`-i "path1/ path2/"` — restrict markdown file discovery to specific directories (default: recurse from CWD).
|
|
57
66
|
|
|
67
|
+
### `.mdbrc` configuration
|
|
68
|
+
|
|
69
|
+
A `.mdbrc` file in the project root provides default values for CLI flags, generated by `mdb init`. Supported keys: `include`, `compaction`, `verbose`. CLI flags always override `.mdbrc` values.
|
|
70
|
+
|
|
58
71
|
## Key Behaviors
|
|
59
72
|
|
|
60
73
|
- 🌀 markers across all files execute first (pull phase), then 💎 markers (push phase)
|
|
@@ -29,7 +29,7 @@ def resolve_paths(patterns: list[str]) -> tuple[list[str], list[str]]:
|
|
|
29
29
|
"""Resolve a list of glob patterns into deduplicated, sorted markdown file paths.
|
|
30
30
|
|
|
31
31
|
Args:
|
|
32
|
-
patterns: List of glob pattern strings (e.g., ["
|
|
32
|
+
patterns: List of glob pattern strings (e.g., ["**/*.md"], ["docs/*"]).
|
|
33
33
|
|
|
34
34
|
Returns:
|
|
35
35
|
Tuple of (files, warnings):
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Advisory file locking for concurrency safety."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
import sys
|
|
4
5
|
import time
|
|
5
6
|
from contextlib import contextmanager
|
|
@@ -53,24 +54,38 @@ else:
|
|
|
53
54
|
def batch_lock(filepaths):
|
|
54
55
|
"""Context manager: acquire exclusive locks on all filepaths in sorted order.
|
|
55
56
|
|
|
57
|
+
Uses separate .lock sidecar files so that atomic_write (which replaces the
|
|
58
|
+
data file via os.replace) does not invalidate the lock fd's inode — avoids
|
|
59
|
+
EIO on close() in overlayfs / container environments.
|
|
60
|
+
|
|
56
61
|
On timeout or error, releases all previously acquired locks.
|
|
57
62
|
"""
|
|
58
63
|
sorted_paths = sorted(filepaths)
|
|
59
|
-
held = [] # list of open file
|
|
64
|
+
held = [] # list of (lock_path, open file object)
|
|
60
65
|
try:
|
|
61
66
|
for filepath in sorted_paths:
|
|
62
|
-
|
|
67
|
+
if not os.path.exists(filepath):
|
|
68
|
+
raise FileNotFoundError(f"No such file: '{filepath}'")
|
|
69
|
+
lock_path = filepath + ".lock"
|
|
70
|
+
fd = open(lock_path, "a")
|
|
63
71
|
try:
|
|
64
72
|
_lock(fd)
|
|
65
73
|
except BaseException:
|
|
66
74
|
fd.close()
|
|
67
75
|
raise
|
|
68
|
-
held.append(fd)
|
|
76
|
+
held.append((lock_path, fd))
|
|
69
77
|
yield
|
|
70
78
|
finally:
|
|
71
|
-
for fd in reversed(held):
|
|
79
|
+
for lock_path, fd in reversed(held):
|
|
72
80
|
try:
|
|
73
81
|
_unlock(fd)
|
|
74
82
|
except OSError:
|
|
75
83
|
pass
|
|
76
|
-
|
|
84
|
+
try:
|
|
85
|
+
fd.close()
|
|
86
|
+
except OSError:
|
|
87
|
+
pass
|
|
88
|
+
try:
|
|
89
|
+
os.unlink(lock_path)
|
|
90
|
+
except OSError:
|
|
91
|
+
pass
|